Repository: vadimdemedes/ink Branch: master Commit: 243e962f7e4f Files: 225 Total size: 746.9 KB Directory structure: gitextract_jq_9codb/ ├── .editorconfig ├── .gitattributes ├── .github/ │ └── workflows/ │ └── test.yml ├── .gitignore ├── .npmrc ├── benchmark/ │ ├── simple/ │ │ ├── index.ts │ │ └── simple.tsx │ └── static/ │ ├── index.ts │ └── static.tsx ├── examples/ │ ├── alternate-screen/ │ │ ├── alternate-screen.tsx │ │ └── index.ts │ ├── aria/ │ │ ├── aria.tsx │ │ └── index.ts │ ├── borders/ │ │ ├── borders.tsx │ │ └── index.ts │ ├── box-backgrounds/ │ │ ├── box-backgrounds.tsx │ │ └── index.ts │ ├── chat/ │ │ ├── chat.tsx │ │ └── index.ts │ ├── concurrent-suspense/ │ │ ├── concurrent-suspense.tsx │ │ └── index.ts │ ├── counter/ │ │ ├── counter.tsx │ │ └── index.ts │ ├── cursor-ime/ │ │ ├── cursor-ime.tsx │ │ └── index.ts │ ├── incremental-rendering/ │ │ ├── incremental-rendering.tsx │ │ └── index.ts │ ├── jest/ │ │ ├── index.ts │ │ ├── jest.tsx │ │ ├── summary.tsx │ │ └── test.tsx │ ├── justify-content/ │ │ ├── index.ts │ │ └── justify-content.tsx │ ├── render-throttle/ │ │ └── index.tsx │ ├── router/ │ │ ├── index.ts │ │ └── router.tsx │ ├── select-input/ │ │ ├── index.ts │ │ └── select-input.tsx │ ├── static/ │ │ ├── index.ts │ │ └── static.tsx │ ├── subprocess-output/ │ │ ├── index.ts │ │ └── subprocess-output.tsx │ ├── suspense/ │ │ ├── index.ts │ │ └── suspense.tsx │ ├── table/ │ │ ├── index.ts │ │ └── table.tsx │ ├── terminal-resize/ │ │ ├── index.ts │ │ └── terminal-resize.tsx │ ├── use-focus/ │ │ ├── index.ts │ │ └── use-focus.tsx │ ├── use-focus-with-id/ │ │ ├── index.ts │ │ └── use-focus-with-id.tsx │ ├── use-input/ │ │ ├── index.ts │ │ └── use-input.tsx │ ├── use-stderr/ │ │ ├── index.ts │ │ └── use-stderr.tsx │ ├── use-stdout/ │ │ ├── index.ts │ │ └── use-stdout.tsx │ └── use-transition/ │ ├── index.ts │ └── use-transition.tsx ├── license ├── media/ │ └── demo.js ├── package.json ├── readme.md ├── recipes/ │ └── routing.md ├── src/ │ ├── ansi-tokenizer.ts │ ├── colorize.ts │ ├── components/ │ │ ├── AccessibilityContext.ts │ │ ├── App.tsx │ │ ├── AppContext.ts │ │ ├── BackgroundContext.ts │ │ ├── Box.tsx │ │ ├── CursorContext.ts │ │ ├── ErrorBoundary.tsx │ │ ├── ErrorOverview.tsx │ │ ├── FocusContext.ts │ │ ├── Newline.tsx │ │ ├── Spacer.tsx │ │ ├── Static.tsx │ │ ├── StderrContext.ts │ │ ├── StdinContext.ts │ │ ├── StdoutContext.ts │ │ ├── Text.tsx │ │ └── Transform.tsx │ ├── cursor-helpers.ts │ ├── devtools-window-polyfill.ts │ ├── devtools.ts │ ├── dom.ts │ ├── get-max-width.ts │ ├── global.d.ts │ ├── hooks/ │ │ ├── use-app.ts │ │ ├── use-box-metrics.ts │ │ ├── use-cursor.ts │ │ ├── use-focus-manager.ts │ │ ├── use-focus.ts │ │ ├── use-input.ts │ │ ├── use-is-screen-reader-enabled.ts │ │ ├── use-paste.ts │ │ ├── use-stderr.ts │ │ ├── use-stdin.ts │ │ ├── use-stdout.ts │ │ └── use-window-size.ts │ ├── index.ts │ ├── ink.tsx │ ├── input-parser.ts │ ├── instances.ts │ ├── kitty-keyboard.ts │ ├── log-update.ts │ ├── measure-element.ts │ ├── measure-text.ts │ ├── output.ts │ ├── parse-keypress.ts │ ├── reconciler.ts │ ├── render-background.ts │ ├── render-border.ts │ ├── render-node-to-output.ts │ ├── render-to-string.ts │ ├── render.ts │ ├── renderer.ts │ ├── sanitize-ansi.ts │ ├── squash-text-nodes.ts │ ├── styles.ts │ ├── utils.ts │ ├── wrap-text.ts │ └── write-synchronized.ts ├── test/ │ ├── alternate-screen-example.tsx │ ├── ansi-tokenizer.ts │ ├── background.tsx │ ├── borders.tsx │ ├── components.tsx │ ├── cursor-helpers.tsx │ ├── cursor.tsx │ ├── display.tsx │ ├── errors.tsx │ ├── exit.tsx │ ├── fixtures/ │ │ ├── alternate-screen-full-board-win.tsx │ │ ├── ci-debug-after-exit.tsx │ │ ├── ci-debug.tsx │ │ ├── ci.tsx │ │ ├── clear.tsx │ │ ├── console.tsx │ │ ├── erase-with-state-change.tsx │ │ ├── erase-with-static.tsx │ │ ├── erase.tsx │ │ ├── exit-double-raw-mode.tsx │ │ ├── exit-normally.tsx │ │ ├── exit-on-exit-with-error-value-property.tsx │ │ ├── exit-on-exit-with-error.tsx │ │ ├── exit-on-exit-with-result.tsx │ │ ├── exit-on-exit-with-value-object.tsx │ │ ├── exit-on-exit.tsx │ │ ├── exit-on-finish.tsx │ │ ├── exit-on-unmount.tsx │ │ ├── exit-raw-on-exit-with-error.tsx │ │ ├── exit-raw-on-exit.tsx │ │ ├── exit-raw-on-unmount.tsx │ │ ├── exit-with-static.tsx │ │ ├── exit-with-thrown-error.tsx │ │ ├── fullscreen-no-extra-newline.tsx │ │ ├── issue-442-full-height.tsx │ │ ├── issue-450-fixture-helpers.tsx │ │ ├── issue-450-full-height-rerender-with-marker.tsx │ │ ├── issue-450-full-height-rerender.tsx │ │ ├── issue-450-full-height-with-static-rerender.tsx │ │ ├── issue-450-grow-to-fullscreen-rerender.tsx │ │ ├── issue-450-grow-to-overflow-rerender.tsx │ │ ├── issue-450-height-minus-one-rerender.tsx │ │ ├── issue-450-initial-fullscreen.tsx │ │ ├── issue-450-initial-overflow.tsx │ │ ├── issue-450-shrink-from-fullscreen-rerender.tsx │ │ ├── issue-450-shrink-from-overflow-rerender.tsx │ │ ├── issue-450-static-shrink-from-fullscreen-rerender.tsx │ │ ├── issue-725-child-process.tsx │ │ ├── use-input-ctrl-c.tsx │ │ ├── use-input-discrete-priority.tsx │ │ ├── use-input-kitty.tsx │ │ ├── use-input-many.tsx │ │ ├── use-input-multiple.tsx │ │ ├── use-input.tsx │ │ ├── use-paste.tsx │ │ └── use-stdout.tsx │ ├── flex-align-content.tsx │ ├── flex-align-items.tsx │ ├── flex-align-self.tsx │ ├── flex-direction.tsx │ ├── flex-justify-content.tsx │ ├── flex-wrap.tsx │ ├── flex.tsx │ ├── focus.tsx │ ├── gap.tsx │ ├── helpers/ │ │ ├── create-stdin.ts │ │ ├── create-stdout.ts │ │ ├── force-colors.ts │ │ ├── render-to-string.ts │ │ ├── run.ts │ │ ├── term.ts │ │ └── test-renderer.ts │ ├── hooks-use-input-kitty.tsx │ ├── hooks-use-input-navigation.tsx │ ├── hooks-use-input.tsx │ ├── hooks-use-paste.tsx │ ├── hooks.tsx │ ├── input-parser.ts │ ├── kitty-keyboard.tsx │ ├── log-update.tsx │ ├── margin.tsx │ ├── measure-element.tsx │ ├── measure-text.tsx │ ├── overflow.tsx │ ├── padding.tsx │ ├── position.tsx │ ├── reconciler.tsx │ ├── render-to-string.tsx │ ├── render.tsx │ ├── sanitize-ansi.ts │ ├── screen-reader.tsx │ ├── terminal-resize.tsx │ ├── text-width.tsx │ ├── text.tsx │ ├── tsconfig.json │ ├── use-box-metrics.tsx │ ├── width-height.tsx │ └── write-synchronized.tsx ├── tsconfig.json └── xo.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = tab end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.yml] indent_style = space indent_size = 2 ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: [push, pull_request] jobs: test: name: Node.js ${{ matrix.node_version }} runs-on: ubuntu-latest strategy: matrix: node_version: - 24 - 22 - 20 steps: - uses: actions/checkout@v6 - name: Use Node.js ${{ matrix.node_version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node_version }} - run: npm install - run: npm test -- --serial env: FORCE_COLOR: true CI: false ================================================ FILE: .gitignore ================================================ node_modules /build ================================================ FILE: .npmrc ================================================ package-lock=false ================================================ FILE: benchmark/simple/index.ts ================================================ import './simple.js'; ================================================ FILE: benchmark/simple/simple.tsx ================================================ import React from 'react'; import {render, Box, Text} from '../../src/index.js'; function App() { return ( {/* eslint-disable-next-line react/jsx-curly-brace-presence */} {'Hello World'} Cupcake ipsum dolor sit amet candy candy. Sesame snaps cookie I love tootsie roll apple pie bonbon wafer. Caramels sesame snaps icing cotton candy I love cookie sweet roll. I love bonbon sweet. Colors: - Red - Blue - Green ); } const {rerender} = render(); for (let index = 0; index < 100_000; index++) { rerender(); } ================================================ FILE: benchmark/static/index.ts ================================================ import './static.js'; ================================================ FILE: benchmark/static/static.tsx ================================================ import React from 'react'; import {render, Box, Text, Static} from '../../src/index.js'; function App() { const [items, setItems] = React.useState< Array<{ id: number; }> >([]); const itemCountReference = React.useRef(0); React.useEffect(() => { let timer: NodeJS.Timeout | undefined; const run = () => { if (itemCountReference.current++ > 1000) { return; } setItems(previousItems => [ ...previousItems, { id: previousItems.length, }, ]); timer = setTimeout(run, 10); }; run(); return () => { clearTimeout(timer); }; }, []); return ( {(item, index) => ( Item #{index} Item content )} {/* eslint-disable-next-line react/jsx-curly-brace-presence */} {'Hello World'} Rendered: {items.length} Cupcake ipsum dolor sit amet candy candy. Sesame snaps cookie I love tootsie roll apple pie bonbon wafer. Caramels sesame snaps icing cotton candy I love cookie sweet roll. I love bonbon sweet. Colors: - Red - Blue - Green ); } render(); ================================================ FILE: examples/alternate-screen/alternate-screen.tsx ================================================ import React, {useReducer, useEffect, useRef, useCallback} from 'react'; import { render, Text, Box, useInput, useApp, useWindowSize, } from '../../src/index.js'; type Point = { x: number; y: number; }; type Direction = 'up' | 'down' | 'left' | 'right'; type GameState = { snake: Point[]; food: Point; score: number; gameOver: boolean; won: boolean; frame: number; }; type Action = {type: 'tick'; direction: Direction} | {type: 'restart'}; const headCharacter = '🦄'; const bodyCharacter = '✨'; const foodCharacter = '🌈'; const emptyCell = ' '; const tickMs = 150; const boardWidth = 20; const boardHeight = 15; const opposites: Record = { up: 'down', down: 'up', left: 'right', right: 'left', }; const offsets: Record = { up: {x: 0, y: -1}, down: {x: 0, y: 1}, left: {x: -1, y: 0}, right: {x: 1, y: 0}, }; const rainbowColors = [ 'red', '#FF7F00', 'yellow', 'green', 'cyan', 'blue', 'magenta', ] as const; const borderH = '─'.repeat(boardWidth * 2); const borderTop = `┌${borderH}┐`; const borderBottom = `└${borderH}┘`; const boardWidthChars = boardWidth * 2 + 2; const initialSnake: Point[] = [ {x: 10, y: 7}, {x: 9, y: 7}, {x: 8, y: 7}, ]; function randomPosition(exclude: Point[]): Point { let point = { x: 0, y: 0, }; let isExcluded = true; while (isExcluded) { point = { x: Math.floor(Math.random() * boardWidth), y: Math.floor(Math.random() * boardHeight), }; isExcluded = false; for (const segment of exclude) { if (segment.x === point.x && segment.y === point.y) { isExcluded = true; break; } } } return point; } function createInitialState(): GameState { return { snake: initialSnake, food: randomPosition(initialSnake), score: 0, gameOver: false, won: false, frame: 0, }; } export function gameReducer(state: GameState, action: Action): GameState { if (action.type === 'restart') { return createInitialState(); } if (state.gameOver) { return state; } const head = state.snake[0]!; const offset = offsets[action.direction]; const newHead: Point = {x: head.x + offset.x, y: head.y + offset.y}; // Wall collision if ( newHead.x < 0 || newHead.x >= boardWidth || newHead.y < 0 || newHead.y >= boardHeight ) { return {...state, gameOver: true, won: false}; } const ateFood = newHead.x === state.food.x && newHead.y === state.food.y; const collisionSegments = ateFood ? state.snake : state.snake.slice(0, -1); if ( collisionSegments.some( segment => segment.x === newHead.x && segment.y === newHead.y, ) ) { return {...state, gameOver: true, won: false}; } const newSnake = [newHead, ...state.snake]; if (!ateFood) { newSnake.pop(); } if (ateFood && newSnake.length === boardWidth * boardHeight) { return { snake: newSnake, food: state.food, score: state.score + 1, gameOver: true, won: true, frame: state.frame + 1, }; } return { snake: newSnake, food: ateFood ? randomPosition(newSnake) : state.food, score: state.score + (ateFood ? 1 : 0), gameOver: false, won: false, frame: state.frame + 1, }; } function buildBoard(snake: Point[], food: Point): string { const headKey = `${snake[0]!.x},${snake[0]!.y}`; const snakeSet = new Set(snake.map(segment => `${segment.x},${segment.y}`)); const rows: string[] = [borderTop]; for (let y = 0; y < boardHeight; y++) { let row = '│'; for (let x = 0; x < boardWidth; x++) { const key = `${x},${y}`; if (key === headKey) { row += headCharacter; } else if (snakeSet.has(key)) { row += bodyCharacter; } else if (food.x === x && food.y === y) { row += foodCharacter; } else { row += emptyCell; } } row += '│'; rows.push(row); } rows.push(borderBottom); return rows.join('\n'); } function SnakeGame() { const {exit} = useApp(); const {columns} = useWindowSize(); const [game, dispatch] = useReducer( gameReducer, undefined, createInitialState, ); const directionReference = useRef('right'); const tick = useCallback(() => { dispatch({type: 'tick', direction: directionReference.current}); }, []); useEffect(() => { const timer = setInterval(tick, tickMs); return () => { clearInterval(timer); }; }, [tick]); useInput((input, key) => { if (input === 'q') { exit(); } if (game.gameOver && input === 'r') { directionReference.current = 'right'; dispatch({type: 'restart'}); return; } if (game.gameOver) { return; } const {current} = directionReference; if (key.upArrow && current !== 'down') { directionReference.current = 'up'; } else if (key.downArrow && current !== 'up') { directionReference.current = 'down'; } else if (key.leftArrow && current !== 'right') { directionReference.current = 'left'; } else if (key.rightArrow && current !== 'left') { directionReference.current = 'right'; } }); const titleColor = rainbowColors[game.frame % rainbowColors.length]!; const board = buildBoard(game.snake, game.food); const marginLeft = Math.max(Math.floor((columns - boardWidthChars) / 2), 0); return ( 🦄 Unicorn Snake 🦄 Score: {game.score} {board} {game.gameOver ? ( {game.won ? 'You Win!' : 'Game Over!'}{' '} r: restart | q: quit ) : ( Arrow keys: move | Eat {foodCharacter} to grow | q: quit )} ); } export function runAlternateScreenExample() { render(, {alternateScreen: true}); } ================================================ FILE: examples/alternate-screen/index.ts ================================================ import {runAlternateScreenExample} from './alternate-screen.js'; runAlternateScreenExample(); ================================================ FILE: examples/aria/aria.tsx ================================================ import React, {useState} from 'react'; import {render, Text, Box, useInput} from '../../src/index.js'; function AriaExample() { const [checked, setChecked] = useState(false); useInput(key => { if (key === ' ') { setChecked(!checked); } }); return ( Press spacebar to toggle the checkbox. This example is best experienced with a screen reader. {checked ? '[x]' : '[ ]'} ); } render(); ================================================ FILE: examples/aria/index.ts ================================================ import './aria.js'; ================================================ FILE: examples/borders/borders.tsx ================================================ import React from 'react'; import {render, Box, Text} from '../../src/index.js'; function Borders() { return ( single double round bold singleDouble doubleSingle classic ); } render(); ================================================ FILE: examples/borders/index.ts ================================================ import './borders.js'; ================================================ FILE: examples/box-backgrounds/box-backgrounds.tsx ================================================ import React from 'react'; import {Box, Text} from '../../src/index.js'; function BoxBackgrounds() { return ( Box Background Examples: 1. Standard red background (10x3): Hello 2. Blue background with border (12x4): Border 3. Green background with padding (14x4): Padding 4. Yellow background with center alignment (16x3): Centered 5. Magenta background, column layout (12x5): Line 1 Line 2 6. Hex color background #FF8800 (10x3): Hex 7. RGB background rgb(0,255,0) (10x3): RGB 8. Text inheritance test: Inherited Override Back to inherited 9. Nested background inheritance: Outer: Inner: Deep Press Ctrl+C to exit ); } export default BoxBackgrounds; ================================================ FILE: examples/box-backgrounds/index.ts ================================================ #!/usr/bin/env node import React from 'react'; import {render} from '../../src/index.js'; import BoxBackgrounds from './box-backgrounds.js'; render(React.createElement(BoxBackgrounds)); ================================================ FILE: examples/chat/chat.tsx ================================================ import React, {useState} from 'react'; import {render, Text, Box, useInput} from '../../src/index.js'; let messageId = 0; function ChatApp() { const [input, setInput] = useState(''); const [messages, setMessages] = useState< Array<{ id: number; text: string; }> >([]); useInput((character, key) => { if (key.return) { if (input) { setMessages(previousMessages => [ ...previousMessages, { id: messageId++, text: `User: ${input}`, }, ]); setInput(''); } } else if (key.backspace || key.delete) { setInput(currentInput => currentInput.slice(0, -1)); } else { setInput(currentInput => currentInput + character); } }); return ( {messages.map(message => ( {message.text} ))} Enter your message: {input} ); } render(); ================================================ FILE: examples/chat/index.ts ================================================ import './chat.js'; ================================================ FILE: examples/concurrent-suspense/concurrent-suspense.tsx ================================================ import React, {Suspense, useState} from 'react'; import {render, Box, Text} from '../../src/index.js'; // Simulated async data fetching with cache const cache = new Map< string, {status: string; data?: string; promise?: Promise} >(); function fetchData(key: string, delay: number): string { const cached = cache.get(key); if (cached?.status === 'resolved') { return cached.data!; } if (cached?.status === 'pending') { // eslint-disable-next-line @typescript-eslint/only-throw-error throw cached.promise; } // Start fetching const promise = new Promise(resolve => { setTimeout(() => { cache.set(key, { status: 'resolved', data: `Data for "${key}" (fetched in ${delay}ms)`, }); resolve(); }, delay); }); cache.set(key, {status: 'pending', promise}); // eslint-disable-next-line @typescript-eslint/only-throw-error throw promise; } // Component that suspends while fetching function DataItem({ name, delay, }: { readonly name: string; readonly delay: number; }) { const data = fetchData(name, delay); return ( {data} ); } // Loading fallback function Loading({message}: {readonly message: string}) { return ( {message} ); } // Main app demonstrating concurrent suspense function App() { const [showMore, setShowMore] = useState(false); // Auto-trigger "show more" after 2 seconds React.useEffect(() => { const timer = setTimeout(() => { setShowMore(true); }, 2000); return () => { clearTimeout(timer); }; }, []); return ( Concurrent Suspense Demo (With concurrent: true, Suspense re-renders automatically) Fast data (200ms): }> Medium data (800ms): }> Slow data (1500ms): }> {showMore ? ( <> Dynamically added (500ms): }> ) : null} ); } // Render with concurrent mode enabled render(, {concurrent: true}); ================================================ FILE: examples/concurrent-suspense/index.ts ================================================ import './concurrent-suspense.js'; ================================================ FILE: examples/counter/counter.tsx ================================================ import React from 'react'; import {render, Text} from '../../src/index.js'; function Counter() { const [counter, setCounter] = React.useState(0); React.useEffect(() => { const timer = setInterval(() => { setCounter(prevCounter => prevCounter + 1); // eslint-disable-line unicorn/prevent-abbreviations }, 100); return () => { clearInterval(timer); }; }, []); return {counter} tests passed; } render(); ================================================ FILE: examples/counter/index.ts ================================================ import './counter.js'; ================================================ FILE: examples/cursor-ime/cursor-ime.tsx ================================================ import React, {useState} from 'react'; import stringWidth from 'string-width'; import {render, Box, Text, useInput, useCursor} from '../../src/index.js'; function App() { const [text, setText] = useState(''); const {setCursorPosition} = useCursor(); useInput((input, key) => { if (key.backspace || key.delete) { setText(previous => previous.slice(0, -1)); return; } if (!key.ctrl && !key.meta && input) { setText(previous => previous + input); } }); // Use stringWidth for correct cursor position with wide characters (Korean, CJK, emoji) const prompt = '> '; setCursorPosition({x: stringWidth(prompt + text), y: 1}); return ( Type Korean (Ctrl+C to exit): {prompt} {text} ); } render(); ================================================ FILE: examples/cursor-ime/index.ts ================================================ import './cursor-ime.js'; ================================================ FILE: examples/incremental-rendering/incremental-rendering.tsx ================================================ import React, {useState, useEffect} from 'react'; import { render, Text, Box, useInput, useWindowSize, useApp, } from '../../src/index.js'; const rows = [ 'Server Authentication Module - Handles JWT token validation, OAuth2 flows, and session management across distributed systems', 'Database Connection Pool - Maintains persistent connections to PostgreSQL cluster with automatic failover and load balancing', 'API Gateway Service - Routes incoming HTTP requests to microservices with rate limiting and request transformation', 'User Profile Manager - Caches user data in Redis with write-through policy and invalidation strategies', 'Payment Processing Engine - Integrates with Stripe, PayPal, and Square APIs for transaction processing', 'Email Notification Queue - Processes outbound emails through SendGrid with retry logic and delivery tracking', 'File Storage Handler - Manages S3 bucket operations with multipart uploads and CDN integration', 'Search Indexer Service - Maintains Elasticsearch indices with real-time document updates and reindexing', 'Metrics Aggregation Pipeline - Collects and processes telemetry data for Prometheus and Grafana dashboards', 'WebSocket Connection Manager - Handles real-time bidirectional communication for chat and notifications', 'Cache Invalidation Service - Coordinates distributed cache updates across Redis cluster nodes', 'Background Job Processor - Executes async tasks via RabbitMQ with dead letter queue handling', 'Session Store Manager - Persists user sessions in DynamoDB with TTL and cross-region replication', 'Rate Limiter Module - Enforces API quotas using token bucket algorithm with Redis backend', 'Content Delivery Network - Serves static assets through Cloudflare with edge caching and GZIP compression', 'Logging Aggregator - Streams application logs to ELK stack with structured JSON formatting', 'Health Check Monitor - Performs periodic service health checks with circuit breaker pattern implementation', 'Configuration Manager - Loads environment-specific settings from Consul with hot reload capability', 'Security Scanner Service - Runs automated vulnerability scans and dependency checks on deployed applications', 'Backup Orchestrator - Schedules and executes automated database backups with encryption and versioning', 'Load Balancer Controller - Manages NGINX upstream servers with health-based traffic distribution', 'Container Orchestration - Coordinates Docker container lifecycle via Kubernetes with auto-scaling policies', 'Message Bus Coordinator - Routes events through Apache Kafka topics with guaranteed delivery semantics', 'Analytics Data Warehouse - Aggregates business metrics in Snowflake with incremental ETL processes', 'API Documentation Service - Generates and serves OpenAPI specs with interactive Swagger UI', 'Feature Flag Manager - Controls feature rollouts using LaunchDarkly with user targeting and percentage rollouts', 'Audit Trail Logger - Records all user actions and system events for compliance and security analysis', 'Image Processing Pipeline - Resizes and optimizes uploaded images using Sharp with multiple format outputs', 'Geolocation Service - Resolves IP addresses to geographic coordinates using MaxMind GeoIP2 database', 'Recommendation Engine - Generates personalized content suggestions using collaborative filtering algorithms', ]; const generateLogLine = (index: number, value: number) => { const timestamp = new Date().toLocaleTimeString(); const actions = [ 'PROCESSING', 'COMPLETED', 'UPDATING', 'SYNCING', 'VALIDATING', 'EXECUTING', ]; const action = actions[Math.floor(Math.random() * actions.length)]; return `[${timestamp}] Worker-${index} ${action}: Batch=${value} Throughput=${(Math.random() * 1000).toFixed(0)}req/s Memory=${(Math.random() * 512).toFixed(1)}MB CPU=${(Math.random() * 100).toFixed(1)}%`; }; function IncrementalRendering() { const {exit} = useApp(); const {rows: terminalHeight} = useWindowSize(); // Calculate available space for dynamic content // Header box: ~9 lines (border + content) // Logs box: variable (border + title + log lines) // Services box: variable (border + title + services) // Footer box: ~3 lines // Margins: ~3 lines // Total fixed: ~15 lines, so available = terminalHeight - 15 const availableLines = Math.max(terminalHeight - 15, 10); // Split available space: ~30% for logs, ~70% for services const logLineCount = Math.max(Math.floor(availableLines * 0.3), 3); const serviceCount = Math.min( Math.max(Math.floor(availableLines * 0.7), 5), rows.length, ); const [selectedIndex, setSelectedIndex] = useState(0); const [timestamp, setTimestamp] = useState(new Date().toLocaleTimeString()); const [counter, setCounter] = useState(0); const [fps, setFps] = useState(0); const [progress1, setProgress1] = useState(0); const [progress2, setProgress2] = useState(0); const [progress3, setProgress3] = useState(0); const [randomValue, setRandomValue] = useState(0); const [logLines, setLogLines] = useState( Array.from({length: logLineCount}, (_, i) => generateLogLine(i, 0)), ); // Update timestamp and counter every second to show live updates useEffect(() => { const timer = setInterval(() => { setTimestamp(new Date().toLocaleTimeString()); setCounter(previous => previous + 1); }, 1000); return () => { clearInterval(timer); }; }, []); // Rapid updates to degrade performance - updates every 16ms (~60fps) useEffect(() => { let frameCount = 0; let lastTime = Date.now(); const timer = setInterval(() => { setProgress1(previous => (previous + 1) % 101); setProgress2(previous => (previous + 2) % 101); setProgress3(previous => (previous + 3) % 101); setRandomValue(Math.floor(Math.random() * 1000)); // Update only 1-2 log lines each frame (simulating real log updates) setLogLines(previous => { const newLines = [...previous]; const updateIndex = Math.floor(Math.random() * newLines.length); newLines[updateIndex] = generateLogLine( updateIndex, Math.floor(Math.random() * 1000), ); return newLines; }); // Calculate FPS frameCount++; const now = Date.now(); if (now - lastTime >= 1000) { setFps(frameCount); frameCount = 0; lastTime = now; } }, 16); // ~60 updates per second return () => { clearInterval(timer); }; }, []); useInput((input, key) => { if (key.upArrow) { setSelectedIndex(previousIndex => previousIndex === 0 ? serviceCount - 1 : previousIndex - 1, ); } if (key.downArrow) { setSelectedIndex(previousIndex => previousIndex === serviceCount - 1 ? 0 : previousIndex + 1, ); } if (input === 'q') { exit(); } }); const progressBar = (value: number) => { const filled = Math.floor(value / 5); const empty = 20 - filled; return '█'.repeat(filled) + '░'.repeat(empty); }; return ( Incremental Rendering Demo - incrementalRendering={String(true)} Use ↑/↓ arrows to navigate • Press q to quit • FPS: {fps} Time: {timestamp} • Updates:{' '} {counter} • Random:{' '} {randomValue} Progress 1: {progressBar(progress1)}{' '} {progress1}% Progress 2: {progressBar(progress2)}{' '} {progress2}% Progress 3: {progressBar(progress3)}{' '} {progress3}% Live Logs (only 1-2 lines update per frame): {logLines.map(line => ( {line} ))} System Services Monitor ({serviceCount} of {rows.length} services): {rows.slice(0, serviceCount).map((row, index) => { const isSelected = index === selectedIndex; return ( {isSelected ? '> ' : ' '} {row} ); })} Selected:{' '} {rows.slice(0, serviceCount)[selectedIndex]} ); } render(, {incrementalRendering: true}); ================================================ FILE: examples/incremental-rendering/index.ts ================================================ import './incremental-rendering.js'; ================================================ FILE: examples/jest/index.ts ================================================ import './jest.js'; ================================================ FILE: examples/jest/jest.tsx ================================================ import React from 'react'; import PQueue from 'p-queue'; import delay from 'delay'; import ms from 'ms'; import {Static, Box, render} from '../../src/index.js'; import Summary from './summary.jsx'; import Test from './test.js'; const paths = [ 'tests/login.js', 'tests/signup.js', 'tests/forgot-password.js', 'tests/reset-password.js', 'tests/view-profile.js', 'tests/edit-profile.js', 'tests/delete-profile.js', 'tests/posts.js', 'tests/post.js', 'tests/comments.js', ]; type State = { startTime: number; completedTests: Array<{ path: string; status: string; }>; runningTests: Array<{ path: string; status: string; }>; }; class Jest extends React.Component, State> { constructor(properties: Record) { super(properties); this.state = { startTime: Date.now(), completedTests: [], runningTests: [], }; } render() { const {startTime, completedTests, runningTests} = this.state; return ( {test => ( )} {runningTests.length > 0 && ( {runningTests.map(test => ( ))} )} test.status === 'pass').length} failed={completedTests.filter(test => test.status === 'fail').length} time={ms(Date.now() - startTime)} /> ); } componentDidMount() { const queue = new PQueue({concurrency: 4}); for (const path of paths) { void queue.add(this.runTest.bind(this, path)); } } async runTest(path: string) { this.setState(previousState => ({ runningTests: [ ...previousState.runningTests, { status: 'runs', path, }, ], })); await delay(1000 * Math.random()); this.setState(previousState => ({ runningTests: previousState.runningTests.filter( test => test.path !== path, ), completedTests: [ ...previousState.completedTests, { status: Math.random() < 0.5 ? 'pass' : 'fail', path, }, ], })); } } render(); ================================================ FILE: examples/jest/summary.tsx ================================================ import React from 'react'; import {Box, Text} from '../../src/index.js'; type Properties = { readonly isFinished: boolean; readonly passed: number; readonly failed: number; readonly time: string; }; function Summary({isFinished, passed, failed, time}: Properties) { return ( Test Suites: {failed > 0 && ( {failed} failed,{' '} )} {passed > 0 && ( {passed} passed,{' '} )} {passed + failed} total Time: {time} {isFinished ? ( Ran all test suites. ) : null} ); } export default Summary; ================================================ FILE: examples/jest/test.tsx ================================================ import React from 'react'; import {Box, Text} from '../../src/index.js'; const getBackgroundForStatus = (status: string): string | undefined => { switch (status) { case 'runs': { return 'yellow'; } case 'pass': { return 'green'; } case 'fail': { return 'red'; } default: { return undefined; } } }; type Properties = { readonly status: string; readonly path: string; }; function Test({status, path}: Properties) { return ( {` ${status.toUpperCase()} `} {path.split('/')[0]}/ {path.split('/')[1]} ); } export default Test; ================================================ FILE: examples/justify-content/index.ts ================================================ import './justify-content.js'; ================================================ FILE: examples/justify-content/justify-content.tsx ================================================ import React from 'react'; import {render, Box, Text} from '../../src/index.js'; function JustifyContent() { return ( [ X Y ] flex-start [ X Y ] flex-end [ X Y ] center [ X Y ] space-around [ X Y ] space-between [ X Y ] space-evenly ); } render(); ================================================ FILE: examples/render-throttle/index.tsx ================================================ import React, {useState, useEffect} from 'react'; import {render, Box, Text} from '../../src/index.js'; function App() { const [count, setCount] = useState(0); useEffect(() => { const interval = setInterval(() => { setCount(c => c + 1); }, 10); // Update every 10ms return () => { clearInterval(interval); }; }, []); return ( Counter: {count} This updates every 10ms but renders are throttled Press Ctrl+C to exit ); } // Example with custom maxFps render(, { maxFps: 10, // Only render at 10fps (every ~100ms) instead of default 30fps }); ================================================ FILE: examples/router/index.ts ================================================ import './router.js'; ================================================ FILE: examples/router/router.tsx ================================================ import React from 'react'; import {MemoryRouter, Routes, Route, useNavigate} from 'react-router'; import {render, useInput, useApp, Box, Text} from '../../src/index.js'; function Home() { const {exit} = useApp(); const navigate = useNavigate(); useInput((input, key) => { if (input === 'q') { exit(); } if (key.return) { void navigate('/about'); } }); return ( Home Press Enter to go to About, or "q" to quit. ); } function About() { const {exit} = useApp(); const navigate = useNavigate(); useInput((input, key) => { if (input === 'q') { exit(); } if (key.return) { void navigate('/'); } }); return ( About Press Enter to go back Home, or "q" to quit. ); } function App() { return ( } /> } /> ); } render(); ================================================ FILE: examples/select-input/index.ts ================================================ import './select-input.js'; ================================================ FILE: examples/select-input/select-input.tsx ================================================ import React, {useState} from 'react'; import { render, Text, Box, useInput, useIsScreenReaderEnabled, } from '../../src/index.js'; const items = ['Red', 'Green', 'Blue', 'Yellow', 'Magenta', 'Cyan']; function SelectInput() { const [selectedIndex, setSelectedIndex] = useState(0); const isScreenReaderEnabled = useIsScreenReaderEnabled(); useInput((input, key) => { if (key.upArrow) { setSelectedIndex(previousIndex => previousIndex === 0 ? items.length - 1 : previousIndex - 1, ); } if (key.downArrow) { setSelectedIndex(previousIndex => previousIndex === items.length - 1 ? 0 : previousIndex + 1, ); } if (isScreenReaderEnabled) { const number = Number.parseInt(input, 10); if (!Number.isNaN(number) && number > 0 && number <= items.length) { setSelectedIndex(number - 1); } } }); return ( Select a color: {items.map((item, index) => { const isSelected = index === selectedIndex; const label = isSelected ? `> ${item}` : ` ${item}`; const screenReaderLabel = `${index + 1}. ${item}`; return ( {label} ); })} ); } render(); ================================================ FILE: examples/static/index.ts ================================================ import './static.js'; ================================================ FILE: examples/static/static.tsx ================================================ import React from 'react'; import {Box, Text, render, Static} from '../../src/index.js'; function Example() { const [tests, setTests] = React.useState< Array<{ id: number; title: string; }> >([]); React.useEffect(() => { let completedTests = 0; let timer: NodeJS.Timeout | undefined; const run = () => { if (completedTests++ < 10) { setTests(previousTests => [ ...previousTests, { id: previousTests.length, title: `Test #${previousTests.length + 1}`, }, ]); timer = setTimeout(run, 100); } }; run(); return () => { clearTimeout(timer); }; }, []); return ( <> {test => ( ✔ {test.title} )} Completed tests: {tests.length} ); } render(); ================================================ FILE: examples/subprocess-output/index.ts ================================================ import './subprocess-output.js'; ================================================ FILE: examples/subprocess-output/subprocess-output.tsx ================================================ import childProcess from 'node:child_process'; import type {Buffer} from 'node:buffer'; import React from 'react'; import stripAnsi from 'strip-ansi'; import {render, Text, Box} from '../../src/index.js'; function SubprocessOutput() { const [output, setOutput] = React.useState(''); React.useEffect(() => { const subProcess = childProcess.spawn('npm', [ 'run', 'example', 'examples/jest', ]); // eslint-disable-next-line @typescript-eslint/no-restricted-types subProcess.stdout.on('data', (newOutput: Buffer) => { const lines = stripAnsi(newOutput.toString('utf8')).split('\n'); setOutput(lines.slice(-5).join('\n')); }); }, [setOutput]); return ( Сommand output: {output} ); } render(); ================================================ FILE: examples/suspense/index.ts ================================================ import './suspense.js'; ================================================ FILE: examples/suspense/suspense.tsx ================================================ import React from 'react'; import {render, Text} from '../../src/index.js'; let promise: Promise | undefined; let state: string | undefined; let value: string | undefined; const read = () => { if (!promise) { promise = new Promise(resolve => { setTimeout(resolve, 500); }); state = 'pending'; (async () => { await promise; state = 'done'; value = 'Hello World'; })(); } if (state === 'pending') { // eslint-disable-next-line @typescript-eslint/only-throw-error throw promise; } if (state === 'done') { return value; } }; function Example() { const message = read(); return {message}; } function Fallback() { return Loading...; } render( }> , ); ================================================ FILE: examples/table/index.ts ================================================ import './table.js'; ================================================ FILE: examples/table/table.tsx ================================================ import React from 'react'; import {faker} from '@faker-js/faker'; import {Box, Text, render} from '../../src/index.js'; const users = Array.from({length: 10}) .fill(true) .map((_, index) => ({ id: index, name: faker.internet.username(), email: faker.internet.email(), })); function Table() { return ( ID Name Email {users.map(user => ( {user.id} {user.name} {user.email} ))} ); } render(); ================================================ FILE: examples/terminal-resize/index.ts ================================================ import './terminal-resize.js'; ================================================ FILE: examples/terminal-resize/terminal-resize.tsx ================================================ import React from 'react'; import {render, Box, Text, useWindowSize} from '../../src/index.js'; function TerminalResizeExample() { const {columns, rows} = useWindowSize(); return ( Terminal Size Columns: {columns} Rows: {rows} Resize your terminal to see the values update. Press Ctrl+C to exit. ); } render(, { patchConsole: true, exitOnCtrlC: true, }); ================================================ FILE: examples/use-focus/index.ts ================================================ import './use-focus.js'; ================================================ FILE: examples/use-focus/use-focus.tsx ================================================ import React from 'react'; import {Box, Text, render, useFocus} from '../../src/index.js'; function Focus() { return ( Press Tab to focus next element, Shift+Tab to focus previous element, Esc to reset focus. ); } function Item({label}) { const {isFocused} = useFocus(); return ( {label} {isFocused ? (focused) : null} ); } render(); ================================================ FILE: examples/use-focus-with-id/index.ts ================================================ import './use-focus-with-id.js'; ================================================ FILE: examples/use-focus-with-id/use-focus-with-id.tsx ================================================ import React from 'react'; import { render, Box, Text, useFocus, useInput, useFocusManager, } from '../../src/index.js'; function Focus() { const {focus} = useFocusManager(); useInput(input => { if (input === '1') { focus('1'); } if (input === '2') { focus('2'); } if (input === '3') { focus('3'); } }); return ( Press Tab to focus next element, Shift+Tab to focus previous element, Esc to reset focus. ); } type ItemProperties = { readonly id: number; readonly label: string; }; function Item({label, id}: ItemProperties) { const {isFocused} = useFocus({id}); return ( {label} {isFocused ? (focused) : null} ); } render(); ================================================ FILE: examples/use-input/index.ts ================================================ import './use-input.js'; ================================================ FILE: examples/use-input/use-input.tsx ================================================ import React from 'react'; import {render, useInput, useApp, Box, Text} from '../../src/index.js'; function Robot() { const {exit} = useApp(); const [x, setX] = React.useState(1); const [y, setY] = React.useState(1); useInput((input, key) => { if (input === 'q') { exit(); } if (key.leftArrow) { setX(Math.max(1, x - 1)); } if (key.rightArrow) { setX(Math.min(20, x + 1)); } if (key.upArrow) { setY(Math.max(1, y - 1)); } if (key.downArrow) { setY(Math.min(10, y + 1)); } }); return ( Use arrow keys to move the face. Press “q” to exit. ^_^ ); } render(); ================================================ FILE: examples/use-stderr/index.ts ================================================ import './use-stderr.js'; ================================================ FILE: examples/use-stderr/use-stderr.tsx ================================================ import React from 'react'; import {render, Text, useStderr} from '../../src/index.js'; function Example() { const {write} = useStderr(); React.useEffect(() => { const timer = setInterval(() => { write('Hello from Ink to stderr\n'); }, 1000); return () => { clearInterval(timer); }; }, []); return Hello World; } render(); ================================================ FILE: examples/use-stdout/index.ts ================================================ import './use-stdout.js'; ================================================ FILE: examples/use-stdout/use-stdout.tsx ================================================ import React from 'react'; import {render, Box, Text, useStdout} from '../../src/index.js'; function Example() { const {stdout, write} = useStdout(); React.useEffect(() => { const timer = setInterval(() => { write('Hello from Ink to stdout\n'); }, 1000); return () => { clearInterval(timer); }; }, []); return ( Terminal dimensions: Width: {stdout.columns} Height: {stdout.rows} ); } render(); ================================================ FILE: examples/use-transition/index.ts ================================================ import './use-transition.js'; ================================================ FILE: examples/use-transition/use-transition.tsx ================================================ import React, {useState, useMemo, useTransition} from 'react'; import {render, Box, Text, useInput} from '../../src/index.js'; // Generate a large list of items for demonstration function generateItems(filter: string): string[] { const allItems: string[] = []; for (let i = 0; i < 200; i++) { allItems.push( `Item ${i + 1}: ${['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'][i % 5]}`, ); } if (!filter) { return allItems.slice(0, 10); } // Simulate expensive filtering const start = Date.now(); while (Date.now() - start < 100) { // Artificial delay to simulate expensive computation } return allItems .filter(item => item.toLowerCase().includes(filter.toLowerCase())) .slice(0, 10); } function SearchApp() { const [query, setQuery] = useState(''); const [isPending, startTransition] = useTransition(); // This is the "deferred" state that can lag behind const [deferredQuery, setDeferredQuery] = useState(''); // Filtered items based on deferred query (expensive computation) const filteredItems = useMemo( () => generateItems(deferredQuery), [deferredQuery], ); // Handle keyboard input useInput((input, key) => { if (key.backspace || key.delete) { setQuery(previousQuery => previousQuery.slice(0, -1)); startTransition(() => { setDeferredQuery(previousQuery => previousQuery.slice(0, -1)); }); } else if (input && !key.ctrl && !key.meta) { setQuery(previousQuery => previousQuery + input); // Wrap the expensive update in a transition startTransition(() => { setDeferredQuery(previousQuery => previousQuery + input); }); } }); return ( useTransition Demo (Type to search - input stays responsive while list updates) Search: {query || '(type something)'} {isPending ? (updating...) : null} Results{' '} {deferredQuery ? `for "${deferredQuery}"` : '(showing first 10)'}: {filteredItems.length === 0 ? ( No items found ) : ( filteredItems.map(item => ( {item} )) )} Press Ctrl+C to exit ); } // Render with concurrent mode enabled (required for useTransition) render(, {concurrent: true}); ================================================ FILE: license ================================================ MIT License Copyright (c) Vadym Demedes (https://github.com/vadimdemedes) Copyright (c) Sindre Sorhus (https://sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: media/demo.js ================================================ import process from 'node:process'; import React from 'react'; import {render, Box, Text} from 'ink'; class Counter extends React.PureComponent { constructor() { super(); this.state = { i: 0, }; } render() { return React.createElement( Box, {flexDirection: 'column'}, React.createElement( Box, {}, React.createElement(Text, {color: 'blue'}, '~/Projects/ink '), ), React.createElement( Box, {}, React.createElement(Text, {color: 'magenta'}, '❯ '), React.createElement(Text, {color: 'green'}, 'node '), React.createElement(Text, {}, 'media/example'), ), React.createElement( Text, {color: 'green'}, `${this.state.i} tests passed`, ), ); } componentDidMount() { this.timer = setInterval(() => { if (this.state.i === 50) { process.exit(0); // eslint-disable-line unicorn/no-process-exit } this.setState(previousState => ({ i: previousState.i + 1, })); }, 100); } componentWillUnmount() { clearInterval(this.timer); } } render(React.createElement(Counter)); ================================================ FILE: package.json ================================================ { "name": "ink", "version": "6.8.0", "description": "React for CLI", "license": "MIT", "repository": "vadimdemedes/ink", "author": { "name": "Vadim Demedes", "email": "vadimdemedes@hey.com", "url": "https://github.com/vadimdemedes" }, "type": "module", "exports": { "types": "./build/index.d.ts", "default": "./build/index.js" }, "engines": { "node": ">=20" }, "scripts": { "dev": "tsc --watch", "build": "tsc", "prepare": "npm run build", "test": "npm run typecheck && npm run lint && FORCE_COLOR=true ava", "lint": "xo", "typecheck": "tsc --noEmit", "example": "NODE_NO_WARNINGS=1 node --import=tsx", "benchmark": "NODE_NO_WARNINGS=1 node --import=tsx", "inspect": "react-devtools" }, "files": [ "build" ], "keywords": [ "react", "cli", "jsx", "stdout", "components", "command-line", "preact", "redux", "print", "render", "colors", "text" ], "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "devDependencies": { "@faker-js/faker": "^10.3.0", "@sindresorhus/tsconfig": "^8.1.0", "@sinonjs/fake-timers": "^15.1.0", "@types/ms": "^2.1.0", "@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": "^21.0.0", "@types/stack-utils": "^2.0.2", "@types/ws": "^8.18.1", "@vdemedes/prettier-config": "^2.0.1", "ava": "^7.0.0", "boxen": "^8.0.1", "delay": "^7.0.0", "ms": "^2.1.3", "node-pty": "^1.2.0-beta.10", "p-queue": "^9.0.0", "prettier": "^3.8.1", "react": "^19.2.4", "react-devtools-core": "^7.0.1", "react-devtools": "^7.0.1", "react-router": "^7.13.0", "sinon": "^21.0.0", "strip-ansi": "^7.1.0", "tsx": "^4.21.0", "typescript": "^5.8.3", "xo": "^1.2.3" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "peerDependenciesMeta": { "@types/react": { "optional": true }, "react-devtools-core": { "optional": true } }, "ava": { "workerThreads": false, "serial": true, "files": [ "test/**/*", "!test/helpers/**/*", "!test/fixtures/**/*" ], "extensions": { "ts": "module", "tsx": "module" }, "nodeArguments": [ "--import=tsx" ] }, "prettier": "@vdemedes/prettier-config" } ================================================ FILE: readme.md ================================================ [![](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md) ---


Ink


> React for CLIs. Build and test your CLI output using components. [![Build Status](https://github.com/vadimdemedes/ink/workflows/test/badge.svg)](https://github.com/vadimdemedes/ink/actions) [![npm](https://img.shields.io/npm/dm/ink?logo=npm)](https://npmjs.com/package/ink) Ink provides the same component-based UI building experience that React offers in the browser, but for command-line apps. It uses [Yoga](https://github.com/facebook/yoga) to build Flexbox layouts in the terminal, so most CSS-like properties are available in Ink as well. If you are already familiar with React, you already know Ink. Since Ink is a React renderer, all features of React are supported. Head over to the [React](https://reactjs.org) website for documentation on how to use it. Only Ink's methods are documented in this readme. --- ## Install ```sh npm install ink react ``` > [!NOTE] > This readme documents the upcoming version of Ink. For the latest stable release, see [Ink on npm](https://www.npmjs.com/package/ink). ## Usage ```jsx import React, {useState, useEffect} from 'react'; import {render, Text} from 'ink'; const Counter = () => { const [counter, setCounter] = useState(0); useEffect(() => { const timer = setInterval(() => { setCounter(previousCounter => previousCounter + 1); }, 100); return () => { clearInterval(timer); }; }, []); return {counter} tests passed; }; render(); ``` ## Who's Using Ink? - [Claude Code](https://github.com/anthropics/claude-code) - An agentic coding tool made by Anthropic. - [Gemini CLI](https://github.com/google-gemini/gemini-cli) - An agentic coding tool made by Google. - [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. - [Cloudflare's Wrangler](https://github.com/cloudflare/wrangler2) - The CLI for Cloudflare Workers. - [Linear](https://linear.app) - Linear built an internal CLI for managing deployments, configs, and other housekeeping tasks. - [Gatsby](https://www.gatsbyjs.org) - Gatsby is a modern web framework for blazing-fast websites. - [tap](https://node-tap.org) - A Test-Anything-Protocol library for JavaScript. - [Terraform CDK](https://github.com/hashicorp/terraform-cdk) - Cloud Development Kit (CDK) for HashiCorp Terraform. - [Specify CLI](https://specifyapp.com) - Automate the distribution of your design tokens. - [Twilio's SIGNAL](https://github.com/twilio-labs/plugin-signal2020) - CLI for Twilio's SIGNAL conference. [Blog post](https://www.twilio.com/blog/building-conference-cli-in-react). - [Typewriter](https://github.com/segmentio/typewriter) - Generates strongly-typed [Segment](https://segment.com) analytics clients from arbitrary JSON Schema. - [Prisma](https://www.prisma.io) - The unified data layer for modern applications. - [Blitz](https://blitzjs.com) - The Fullstack React Framework. - [New York Times](https://github.com/nytimes/kyt) - NYT uses Ink's `kyt` - a toolkit that encapsulates and manages the configuration for web apps. - [tink](https://github.com/npm/tink) - A next-generation runtime and package manager. - [Inkle](https://github.com/jrr/inkle) - A Wordle game. - [loki](https://github.com/oblador/loki) - Visual regression testing tool for Storybook. - [Bit](https://github.com/teambit/bit) - Build, distribute, and collaborate on components. - [Remirror](https://github.com/remirror/remirror) - Your friendly, world-class editor toolkit. - [Prime](https://github.com/birkir/prime) - Open-source GraphQL CMS. - [emoj](https://github.com/sindresorhus/emoj) - Find relevant emojis. - [emma](https://github.com/maticzav/emma-cli) - Find and install npm packages easily. - [npm-check-extras](https://github.com/akgondber/npm-check-extras) - Check for outdated and unused dependencies, and run update/delete actions on selected ones. - [swiff](https://github.com/simple-integrated-marketing/swiff) - Multi-environment command-line tools for time-saving web developers. - [share](https://github.com/marionebl/share-cli) - Share files quickly. - [Kubelive](https://github.com/ameerthehacker/kubelive) - A CLI for Kubernetes that provides live data about the cluster and its resources. - [changelog-view](https://github.com/jdeniau/changelog-view) - View changelogs. - [cfpush](https://github.com/mamachanko/cfpush) - Interactive Cloud Foundry tutorial. - [startd](https://github.com/mgrip/startd) - Turn your React component into a web app. - [wiki-cli](https://github.com/hexrcs/wiki-cli) - Search Wikipedia and read article summaries. - [garson](https://github.com/goliney/garson) - Build interactive, config-based command-line interfaces. - [git-contrib-calendar](https://github.com/giannisp/git-contrib-calendar) - Display a contributions calendar for any Git repository. - [gitgud](https://github.com/GitGud-org/GitGud) - Interactive command-line GUI for Git. - [Autarky](https://github.com/pranshuchittora/autarky) - Find and delete old `node_modules` directories to free up disk space. - [fast-cli](https://github.com/sindresorhus/fast-cli) - Test your download and upload speeds. - [tasuku](https://github.com/privatenumber/tasuku) - Minimal task runner. - [mnswpr](https://github.com/mordv/mnswpr) - A Minesweeper game. - [lrn](https://github.com/krychu/lrn) - Learning by repetition. - [turdle](https://github.com/mynameisankit/turdle) - A Wordle game. - [Shopify CLI](https://github.com/Shopify/cli) - Build apps, themes, and storefronts for the Shopify platform. - [ToDesktop CLI](https://www.todesktop.com/electron) - All-in-one platform for building Electron apps. - [Walle](https://github.com/Pobepto/walle) - A full-featured crypto wallet for EVM networks. - [Sudoku](https://github.com/mrozio13pl/sudoku-in-terminal) - A Sudoku game. - [Sea Trader](https://github.com/zyishai/sea-trader) - A Taipan!-inspired trading simulator game. - [srtd](https://github.com/t1mmen/srtd) - Live-reloading SQL templates for Supabase projects. - [tweakcc](https://github.com/Piebald-AI/tweakcc) - Customize your Claude Code styling. - [argonaut](https://github.com/darksworm/argonaut) - Manage Argo CD resources. - [Qodo Command](https://github.com/qodo-ai/command) - Build, run, and manage AI agents. - [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. *(PRs welcome. Append new entries at the end. Repos must have 100+ stars and showcase Ink beyond a basic list picker.)* ## Contents - [Getting Started](#getting-started) - [App Lifecycle](#app-lifecycle) - [Components](#components) - [``](#text) - [``](#box) - [``](#newline) - [``](#spacer) - [``](#static) - [``](#transform) - [Hooks](#hooks) - [`useInput`](#useinputinputhandler-options) - [`usePaste`](#usepastehandler-options) - [`useApp`](#useapp) - [`useStdin`](#usestdin) - [`useStdout`](#usestdout) - [`useBoxMetrics`](#useboxmetricsref) - [`useStderr`](#usestderr) - [`useWindowSize`](#usewindowsize) - [`useFocus`](#usefocusoptions) - [`useFocusManager`](#usefocusmanager) - [`useCursor`](#usecursor) - [API](#api) - [Testing](#testing) - [Using React Devtools](#using-react-devtools) - [Screen Reader Support](#screen-reader-support) - [Useful Components](#useful-components) - [Useful Hooks](#useful-hooks) - [Recipes](#recipes) - [Examples](#examples) - [Continuous Integration](#continuous-integration) ## Getting Started Use [create-ink-app](https://github.com/vadimdemedes/create-ink-app) to quickly scaffold a new Ink-based CLI. ```sh npx create-ink-app my-ink-cli ``` Alternatively, create a TypeScript project: ```sh npx create-ink-app --typescript my-ink-cli ```
Manual JavaScript setup

Ink requires the same Babel setup as you would do for regular React-based apps in the browser. Set up Babel with a React preset to ensure all examples in this readme work as expected. After [installing Babel](https://babeljs.io/docs/en/usage), install `@babel/preset-react` and insert the following configuration in `babel.config.json`: ```sh npm install --save-dev @babel/preset-react ``` ```json { "presets": ["@babel/preset-react"] } ``` Next, create a file `source.js`, where you'll type code that uses Ink: ```jsx import React from 'react'; import {render, Text} from 'ink'; const Demo = () => Hello World; render(); ``` Then, transpile this file with Babel: ```sh npx babel source.js -o cli.js ``` Now you can run `cli.js` with Node.js: ```sh node cli ``` If you don't like transpiling files during development, you can use [import-jsx](https://github.com/vadimdemedes/import-jsx) or [@esbuild-kit/esm-loader](https://github.com/esbuild-kit/esm-loader) to `import` a JSX file and transpile it on the fly.

Ink uses [Yoga](https://github.com/facebook/yoga), a Flexbox layout engine, to build great user interfaces for your CLIs using familiar CSS-like properties you've used when building apps for the browser. It's important to remember that each element is a Flexbox container. Think of it as if every `
` in the browser had `display: flex`. See [``](#box) built-in component below for documentation on how to use Flexbox layouts in Ink. Note that all text must be wrapped in a [``](#text) component. ## App Lifecycle An Ink app is a Node.js process, so it stays alive only while there is active work in the event loop (timers, pending promises, [`useInput`](#useinputinputhandler-options) listening on `stdin`, etc.). If your component tree has no async work, the app will render once and exit immediately. To exit the app, press **Ctrl+C** (enabled by default via [`exitOnCtrlC`](#exitonctrlc)), call [`exit()`](#exiterrororresult) from [`useApp`](#useapp) inside a component, or call [`unmount()`](#unmount) on the object returned by [`render()`](#rendertree-options). Use [`waitUntilExit()`](#waituntilexit) to run code after the app is unmounted: ```jsx const {waitUntilExit} = render(); await waitUntilExit(); console.log('App exited'); ``` ## Components ### `` This component can display text and change its style to make it bold, underlined, italic, or strikethrough. ```jsx import {render, Text} from 'ink'; const Example = () => ( <> I am green I am black on white I am white I am bold I am italic I am underline I am strikethrough I am inversed ); render(); ``` > [!NOTE] > `` allows only text nodes and nested `` components inside of it. For example, `` component can't be used inside ``. #### color Type: `string` Change text color. Ink uses [chalk](https://github.com/chalk/chalk) under the hood, so all its functionality is supported. ```jsx Green Blue Red ``` #### backgroundColor Type: `string` Same as `color` above, but for background. ```jsx Green Blue Red ``` #### dimColor Type: `boolean`\ Default: `false` Dim the color (make it less bright). ```jsx Dimmed Red ``` #### bold Type: `boolean`\ Default: `false` Make the text bold. #### italic Type: `boolean`\ Default: `false` Make the text italic. #### underline Type: `boolean`\ Default: `false` Make the text underlined. #### strikethrough Type: `boolean`\ Default: `false` Make the text crossed with a line. #### inverse Type: `boolean`\ Default: `false` Invert background and foreground colors. ```jsx Inversed Yellow ``` #### wrap Type: `string`\ Allowed values: `wrap` `truncate` `truncate-start` `truncate-middle` `truncate-end`\ Default: `wrap` This property tells Ink to wrap or truncate text if its width is larger than the container. If `wrap` is passed (the default), Ink will wrap text and split it into multiple lines. If `truncate-*` is passed, Ink will truncate text instead, resulting in one line of text with the rest cut off. ```jsx Hello World //=> 'Hello\nWorld' // `truncate` is an alias to `truncate-end` Hello World //=> 'Hello…' Hello World //=> 'He…ld' Hello World //=> '…World' ``` ### `` `` is an essential Ink component to build your layout. It's like `
` in the browser. ```jsx import {render, Box, Text} from 'ink'; const Example = () => ( This is a box with margin ); render(); ``` #### Dimensions ##### width Type: `number` `string` Width of the element in spaces. You can also set it as a percentage, which will calculate the width based on the width of the parent element. ```jsx X //=> 'X ' ``` ```jsx X Y //=> 'X Y' ``` ##### height Type: `number` `string` Height of the element in lines (rows). You can also set it as a percentage, which will calculate the height based on the height of the parent element. ```jsx X //=> 'X\n\n\n' ``` ```jsx X Y //=> 'X\n\n\nY\n\n' ``` ##### minWidth Type: `number` Sets a minimum width of the element. Percentages aren't supported yet; see https://github.com/facebook/yoga/issues/872. ##### minHeight Type: `number` `string` Sets a minimum height of the element in lines (rows). You can also set it as a percentage, which will calculate the minimum height based on the height of the parent element. ##### maxWidth Type: `number` Sets a maximum width of the element. Percentages aren't supported yet; see https://github.com/facebook/yoga/issues/872. ##### maxHeight Type: `number` `string` Sets a maximum height of the element in lines (rows). You can also set it as a percentage, which will calculate the maximum height based on the height of the parent element. ##### aspectRatio Type: `number` Defines the aspect ratio (width/height) for the element. Use it with at least one size constraint (`width`, `height`, `minHeight`, or `maxHeight`) so Ink can derive the missing dimension. #### Padding ##### paddingTop Type: `number`\ Default: `0` Top padding. ##### paddingBottom Type: `number`\ Default: `0` Bottom padding. ##### paddingLeft Type: `number`\ Default: `0` Left padding. ##### paddingRight Type: `number`\ Default: `0` Right padding. ##### paddingX Type: `number`\ Default: `0` Horizontal padding. Equivalent to setting `paddingLeft` and `paddingRight`. ##### paddingY Type: `number`\ Default: `0` Vertical padding. Equivalent to setting `paddingTop` and `paddingBottom`. ##### padding Type: `number`\ Default: `0` Padding on all sides. Equivalent to setting `paddingTop`, `paddingBottom`, `paddingLeft` and `paddingRight`. ```jsx Top Bottom Left Right Left and right Top and bottom Top, bottom, left and right ``` #### Margin ##### marginTop Type: `number`\ Default: `0` Top margin. ##### marginBottom Type: `number`\ Default: `0` Bottom margin. ##### marginLeft Type: `number`\ Default: `0` Left margin. ##### marginRight Type: `number`\ Default: `0` Right margin. ##### marginX Type: `number`\ Default: `0` Horizontal margin. Equivalent to setting `marginLeft` and `marginRight`. ##### marginY Type: `number`\ Default: `0` Vertical margin. Equivalent to setting `marginTop` and `marginBottom`. ##### margin Type: `number`\ Default: `0` Margin on all sides. Equivalent to setting `marginTop`, `marginBottom`, `marginLeft` and `marginRight`. ```jsx Top Bottom Left Right Left and right Top and bottom Top, bottom, left and right ``` #### Gap #### gap Type: `number`\ Default: `0` Size of the gap between an element's columns and rows. A shorthand for `columnGap` and `rowGap`. ```jsx A B C // A B // // C ``` #### columnGap Type: `number`\ Default: `0` Size of the gap between an element's columns. ```jsx A B // A B ``` #### rowGap Type: `number`\ Default: `0` Size of the gap between an element's rows. ```jsx A B // A // // B ``` #### Flex ##### flexGrow Type: `number`\ Default: `0` See [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/). ```jsx Label: Fills all remaining space ``` ##### flexShrink Type: `number`\ Default: `1` See [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/). ```jsx Will be 1/4 Will be 3/4 ``` ##### flexBasis Type: `number` `string` See [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/). ```jsx X Y //=> 'X Y' ``` ```jsx X Y //=> 'X Y' ``` ##### flexDirection Type: `string`\ Allowed values: `row` `row-reverse` `column` `column-reverse` See [flex-direction](https://css-tricks.com/almanac/properties/f/flex-direction/). ```jsx X Y // X Y X Y // Y X X Y // X // Y X Y // Y // X ``` ##### flexWrap Type: `string`\ Allowed values: `nowrap` `wrap` `wrap-reverse` See [flex-wrap](https://css-tricks.com/almanac/properties/f/flex-wrap/). ```jsx A BC // A // B C ``` ```jsx A B C // A C // B ``` ##### alignItems Type: `string`\ Allowed values: `flex-start` `center` `flex-end` `stretch` `baseline` See [align-items](https://css-tricks.com/almanac/properties/a/align-items/). ```jsx X A B C // X A // B // C X A B C // A // X B // C X A B C // A // B // X C ``` ##### alignSelf Type: `string`\ Default: `auto`\ Allowed values: `auto` `flex-start` `center` `flex-end` `stretch` `baseline` See [align-self](https://css-tricks.com/almanac/properties/a/align-self/). ```jsx X // X // // X // // X // X // // // X ``` ##### alignContent Type: `string`\ Default: `flex-start`\ Allowed values: `flex-start` `flex-end` `center` `stretch` `space-between` `space-around` `space-evenly` Defines alignment between flex lines on the cross axis when `flexWrap` creates multiple lines. See [align-content](https://css-tricks.com/almanac/properties/a/align-content/). Unlike CSS (`stretch`), Ink defaults to `flex-start` so wrapped lines stay compact and fixed-height boxes don't gain unexpected empty rows unless you opt in to stretching. ##### justifyContent Type: `string`\ Allowed values: `flex-start` `center` `flex-end` `space-between` `space-around` `space-evenly` See [justify-content](https://css-tricks.com/almanac/properties/j/justify-content/). ```jsx X // [X ] X // [ X ] X // [ X] X Y // [X Y] X Y // [ X Y ] X Y // [ X Y ] ``` #### Position ##### position Type: `string`\ Allowed values: `relative` `absolute` `static`\ Default: `relative` Controls how the element is positioned. When `position` is `static`, `top`, `right`, `bottom`, and `left` are ignored. ##### top Type: `number` `string` Top offset for positioned elements. You can also set it as a percentage of the parent size. ##### right Type: `number` `string` Right offset for positioned elements. You can also set it as a percentage of the parent size. ##### bottom Type: `number` `string` Bottom offset for positioned elements. You can also set it as a percentage of the parent size. ##### left Type: `number` `string` Left offset for positioned elements. You can also set it as a percentage of the parent size. #### Visibility ##### display Type: `string`\ Allowed values: `flex` `none`\ Default: `flex` Set this property to `none` to hide the element. ##### overflowX Type: `string`\ Allowed values: `visible` `hidden`\ Default: `visible` Behavior for an element's overflow in the horizontal direction. ##### overflowY Type: `string`\ Allowed values: `visible` `hidden`\ Default: `visible` Behavior for an element's overflow in the vertical direction. ##### overflow Type: `string`\ Allowed values: `visible` `hidden`\ Default: `visible` A shortcut for setting `overflowX` and `overflowY` at the same time. #### Borders ##### borderStyle Type: `string`\ Allowed values: `single` `double` `round` `bold` `singleDouble` `doubleSingle` `classic` | `BoxStyle` Add a border with a specified style. If `borderStyle` is `undefined` (the default), no border will be added. Ink uses border styles from the [`cli-boxes`](https://github.com/sindresorhus/cli-boxes) module. ```jsx single double round bold singleDouble doubleSingle classic ``` Alternatively, pass a custom border style like so: ```jsx Custom ``` See example in [examples/borders](examples/borders/borders.tsx). ##### borderColor Type: `string` Change border color. A shorthand for setting `borderTopColor`, `borderRightColor`, `borderBottomColor`, and `borderLeftColor`. ```jsx Green Rounded Box ``` ##### borderTopColor Type: `string` Change top border color. Accepts the same values as [`color`](#color) in `` component. ```jsx Hello world ``` ##### borderRightColor Type: `string` Change the right border color. Accepts the same values as [`color`](#color) in `` component. ```jsx Hello world ``` ##### borderBottomColor Type: `string` Change the bottom border color. Accepts the same values as [`color`](#color) in `` component. ```jsx Hello world ``` ##### borderLeftColor Type: `string` Change the left border color. Accepts the same values as [`color`](#color) in `` component. ```jsx Hello world ``` ##### borderDimColor Type: `boolean`\ Default: `false` Dim the border color. A shorthand for setting `borderTopDimColor`, `borderBottomDimColor`, `borderLeftDimColor`, and `borderRightDimColor`. ```jsx Hello world ``` ##### borderTopDimColor Type: `boolean`\ Default: `false` Dim the top border color. ```jsx Hello world ``` ##### borderBottomDimColor Type: `boolean`\ Default: `false` Dim the bottom border color. ```jsx Hello world ``` ##### borderLeftDimColor Type: `boolean`\ Default: `false` Dim the left border color. ```jsx Hello world ``` ##### borderRightDimColor Type: `boolean`\ Default: `false` Dim the right border color. ```jsx Hello world ``` ##### borderTop Type: `boolean`\ Default: `true` Determines whether the top border is visible. ##### borderRight Type: `boolean`\ Default: `true` Determines whether the right border is visible. ##### borderBottom Type: `boolean`\ Default: `true` Determines whether the bottom border is visible. ##### borderLeft Type: `boolean`\ Default: `true` Determines whether the left border is visible. #### Background ##### backgroundColor Type: `string` Background color for the element. Accepts the same values as [`color`](#color) in the `` component. ```jsx Red background Orange background Green background ``` The background color fills the entire `` area and is inherited by child `` components unless they specify their own `backgroundColor`. ```jsx Blue inherited Yellow override Blue inherited again ``` Background colors work with borders and padding: ```jsx Background with border and padding ``` See example in [examples/box-backgrounds](examples/box-backgrounds/box-backgrounds.tsx). ### `` Adds one or more newline (`\n`) characters. Must be used within `` components. #### count Type: `number`\ Default: `1` Number of newlines to insert. ```jsx import {render, Text, Newline} from 'ink'; const Example = () => ( Hello World ); render(); ``` Output: ``` Hello World ``` ### `` A flexible space that expands along the major axis of its containing layout. It's useful as a shortcut for filling all the available space between elements. For example, using `` in a `` with default flex direction (`row`) will position "Left" on the left side and will push "Right" to the right side. ```jsx import {render, Box, Text, Spacer} from 'ink'; const Example = () => ( Left Right ); render(); ``` In a vertical flex direction (`column`), it will position "Top" at the top of the container and push "Bottom" to the bottom. Note that the container needs to be tall enough to see this in effect. ```jsx import {render, Box, Text, Spacer} from 'ink'; const Example = () => ( Top Bottom ); render(); ``` ### `` `` component permanently renders its output above everything else. It's useful for displaying activity like completed tasks or logs - things that don't change after they're rendered (hence the name "Static"). It's preferred to use `` for use cases like these when you can't know or control the number of items that need to be rendered. For example, [Tap](https://github.com/tapjs/node-tap) uses `` to display a list of completed tests. [Gatsby](https://github.com/gatsbyjs/gatsby) uses it to display a list of generated pages while still displaying a live progress bar. ```jsx import React, {useState, useEffect} from 'react'; import {render, Static, Box, Text} from 'ink'; const Example = () => { const [tests, setTests] = useState([]); useEffect(() => { let completedTests = 0; let timer; const run = () => { // Fake 10 completed tests if (completedTests++ < 10) { setTests(previousTests => [ ...previousTests, { id: previousTests.length, title: `Test #${previousTests.length + 1}` } ]); timer = setTimeout(run, 100); } }; run(); return () => { clearTimeout(timer); }; }, []); return ( <> {/* This part will be rendered once to the terminal */} {test => ( ✔ {test.title} )} {/* This part keeps updating as state changes */} Completed tests: {tests.length} ); }; render(); ``` > [!NOTE] > `` only renders new items in the `items` prop and ignores items that were previously rendered. This means that when you add new items to the `items` array, changes you make to previous items will not trigger a rerender. See [examples/static](examples/static/static.tsx) for an example usage of `` component. #### items Type: `Array` Array of items of any type to render using the function you pass as a component child. #### style Type: `object` Styles to apply to a container of child elements. See [``](#box) for supported properties. ```jsx {...} ``` #### children(item) Type: `Function` Function that is called to render every item in the `items` array. The first argument is the item itself, and the second argument is the index of that item in the `items` array. Note that a `key` must be assigned to the root component. ```jsx {(item, index) => { // This function is called for every item in ['a', 'b', 'c'] // `item` is 'a', 'b', 'c' // `index` is 0, 1, 2 return ( Item: {item} ); }} ``` ### `` Transform a string representation of React components before they're written to output. For example, you might want to apply a [gradient to text](https://github.com/sindresorhus/ink-gradient), [add a clickable link](https://github.com/sindresorhus/ink-link), or [create some text effects](https://github.com/sindresorhus/ink-big-text). These use cases can't accept React nodes as input; they expect a string. That's what the `` component does: it gives you an output string of its child components and lets you transform it in any way. > [!NOTE] > `` must be applied only to `` children components and shouldn't change the dimensions of the output; otherwise, the layout will be incorrect. > [!IMPORTANT] > When children use `` styling props (e.g. `color`, `bold`), the string passed to `transform` will contain [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code). If your transform manipulates whitespace or does string operations like `.trim()`, you may need to use ANSI-aware methods (e.g. from [`slice-ansi`](https://github.com/chalk/slice-ansi) or [`strip-ansi`](https://github.com/chalk/strip-ansi)). ```jsx import {render, Transform} from 'ink'; const Example = () => ( output.toUpperCase()}> Hello World ); render(); ``` Since the `transform` function converts all characters to uppercase, the final output rendered to the terminal will be "HELLO WORLD", not "Hello World". When the output wraps to multiple lines, it can be helpful to know which line is being processed. For example, to implement a hanging indent component, you can indent all the lines except for the first. ```jsx import {render, Transform} from 'ink'; const HangingIndent = ({indent = 4, children}) => ( index === 0 ? line : ' '.repeat(indent) + line } > {children} ); const text = 'WHEN I WROTE the following pages, or rather the bulk of them, ' + 'I lived alone, in the woods, a mile from any neighbor, in a ' + 'house which I had built myself, on the shore of Walden Pond, ' + 'in Concord, Massachusetts, and earned my living by the labor ' + 'of my hands only. I lived there two years and two months. At ' + 'present I am a sojourner in civilized life again.'; render( {text} ); ``` #### transform(outputLine, index) Type: `Function` Function that transforms children output. It accepts children and must return transformed children as well. ##### children Type: `string` Output of child components. ##### index Type: `number` The zero-indexed line number of the line that's currently being transformed. ## Hooks ### useInput(inputHandler, options?) A React hook that returns `void` and handles user input. It's a more convenient alternative to using `useStdin` and listening for `data` events. The callback you pass to `useInput` is called for each character when the user enters any input. However, if the user pastes text and it's more than one character, the callback will be called only once, and the whole string will be passed as `input`. You can find a full example of using `useInput` at [examples/use-input](examples/use-input/use-input.tsx). ```jsx import {useInput} from 'ink'; const UserInput = () => { useInput((input, key) => { if (input === 'q') { // Exit program } if (key.leftArrow) { // Left arrow key pressed } }); return … }; ``` #### inputHandler(input, key) Type: `Function` The handler function that you pass to `useInput` receives two arguments: ##### input Type: `string` The input that the program received. ##### key Type: `object` Handy information about a key that was pressed. ###### key.leftArrow ###### key.rightArrow ###### key.upArrow ###### key.downArrow Type: `boolean`\ Default: `false` If an arrow key was pressed, the corresponding property will be `true`. For example, if the user presses the left arrow key, `key.leftArrow` equals `true`. ###### key.return Type: `boolean`\ Default: `false` Return (Enter) key was pressed. ###### key.escape Type: `boolean`\ Default: `false` Escape key was pressed. ###### key.ctrl Type: `boolean`\ Default: `false` Ctrl key was pressed. ###### key.shift Type: `boolean`\ Default: `false` Shift key was pressed. ###### key.tab Type: `boolean`\ Default: `false` Tab key was pressed. ###### key.backspace Type: `boolean`\ Default: `false` Backspace key was pressed. ###### key.delete Type: `boolean`\ Default: `false` Delete key was pressed. ###### key.pageDown ###### key.pageUp Type: `boolean`\ Default: `false` If the Page Up or Page Down key was pressed, the corresponding property will be `true`. For example, if the user presses Page Down, `key.pageDown` equals `true`. ###### key.home ###### key.end Type: `boolean`\ Default: `false` If the Home or End key was pressed, the corresponding property will be `true`. For example, if the user presses End, `key.end` equals `true`. ###### key.meta Type: `boolean`\ Default: `false` [Meta key](https://en.wikipedia.org/wiki/Meta_key) was pressed. ###### 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 Type: `object` ##### isActive Type: `boolean`\ Default: `true` Enable or disable capturing of user input. Useful when there are multiple `useInput` hooks used at once to avoid handling the same input several times. ### usePaste(handler, options?) A React hook that calls `handler` whenever the user pastes text. Bracketed paste mode (`\x1b[?2004h`) is automatically enabled while the hook is active, so pasted text arrives as a single string rather than being misinterpreted as individual key presses. `usePaste` and `useInput` can be used together in the same component. They operate on separate event channels, so paste content is never forwarded to `useInput` handlers when `usePaste` is active. ```jsx import {useInput, usePaste} from 'ink'; const MyInput = () => { useInput((input, key) => { // Only receives typed characters and key events, not pasted text. if (key.return) { // Submit } }); usePaste((text) => { // Receives the full pasted string, including newlines. console.log('Pasted:', text); }); return … }; ``` #### handler(text) Type: `Function` Called with the full pasted string whenever the user pastes text. The string is delivered verbatim — newlines, escape sequences, and other special characters are preserved exactly as pasted. ##### text Type: `string` The pasted text. #### options Type: `object` ##### isActive Type: `boolean`\ Default: `true` Enable or disable the paste handler. Useful when multiple components use `usePaste` and only one should be active at a time. ### useApp() A React hook that returns app lifecycle methods. #### exit(errorOrResult?) Type: `Function` Exit (unmount) the whole Ink app. ##### errorOrResult Type: `Error | unknown` Optional value that controls how [`waitUntilExit`](#waituntilexit) settles: - `exit()` resolves with `undefined`. - `exit(error)` rejects when `error` is an `Error`. - `exit(value)` resolves with `value`. ```js import {useEffect} from 'react'; import {useApp} from 'ink'; const Example = () => { const {exit} = useApp(); // Exit the app after 5 seconds useEffect(() => { setTimeout(() => { exit(); }, 5000); }, [exit]); return … }; ``` #### waitUntilRenderFlush() Type: `Function` Returns a promise that settles after pending render output is flushed to stdout. ```js import {useEffect} from 'react'; import {useApp} from 'ink'; const Example = () => { const {waitUntilRenderFlush} = useApp(); useEffect(() => { void (async () => { await waitUntilRenderFlush(); runNextCommand(); })(); }, [waitUntilRenderFlush]); return …; }; ``` ### useStdin() A React hook that returns the stdin stream and stdin-related utilities. #### stdin Type: `stream.Readable`\ Default: `process.stdin` The stdin stream passed to `render()` in `options.stdin`, or `process.stdin` by default. Useful if your app needs to handle user input. ```js import {useStdin} from 'ink'; const Example = () => { const {stdin} = useStdin(); return … }; ``` #### isRawModeSupported Type: `boolean` A boolean flag determining if the current `stdin` supports `setRawMode`. A component using `setRawMode` might want to use `isRawModeSupported` to nicely fall back in environments where raw mode is not supported. ```jsx import {useStdin} from 'ink'; const Example = () => { const {isRawModeSupported} = useStdin(); return isRawModeSupported ? ( ) : ( ); }; ``` #### setRawMode(isRawModeEnabled) Type: `function` ##### isRawModeEnabled Type: `boolean` See [`setRawMode`](https://nodejs.org/api/tty.html#tty_readstream_setrawmode_mode). Ink exposes this function to be able to handle Ctrl+C, that's why you should use Ink's `setRawMode` instead of `process.stdin.setRawMode`. **Warning:** This function will throw unless the current `stdin` supports `setRawMode`. Use [`isRawModeSupported`](#israwmodesupported) to detect `setRawMode` support. ```js import {useStdin} from 'ink'; const Example = () => { const {setRawMode} = useStdin(); useEffect(() => { setRawMode(true); return () => { setRawMode(false); }; }); return … }; ``` ### useStdout() A React hook that returns the stdout stream where Ink renders your app and stdout-related utilities. #### stdout Type: `stream.Writable`\ Default: `process.stdout` ```js import {useStdout} from 'ink'; const Example = () => { const {stdout} = useStdout(); return … }; ``` #### write(data) Write any string to stdout while preserving Ink's output. It's useful when you want to display external information outside of Ink's rendering and ensure there's no conflict between the two. It's similar to ``, except it can't accept components; it only works with strings. ##### data Type: `string` Data to write to stdout. ```js import {useStdout} from 'ink'; const Example = () => { const {write} = useStdout(); useEffect(() => { // Write a single message to stdout, above Ink's output write('Hello from Ink to stdout\n'); }, []); return … }; ``` See additional usage example in [examples/use-stdout](examples/use-stdout/use-stdout.tsx). ### useBoxMetrics(ref) A React hook that returns the current layout metrics for a tracked box element. It updates when layout changes (for example terminal resize, sibling/content changes, or position changes). Use `hasMeasured` to detect when the currently tracked element has been measured. #### ref Type: `React.RefObject` A ref to the `` element to track. ```jsx import {useRef} from 'react'; import {Box, Text, useBoxMetrics} from 'ink'; const Example = () => { const ref = useRef(null); const {width, height, left, top, hasMeasured} = useBoxMetrics(ref); return ( {hasMeasured ? `${width}x${height} at ${left},${top}` : 'Measuring...'} ); }; ``` #### width Type: `number` Element width. #### height Type: `number` Element height. #### left Type: `number` Distance from the left edge of the parent. #### top Type: `number` Distance from the top edge of the parent. #### hasMeasured Type: `boolean` Whether the currently tracked element has been measured. > [!NOTE] > The hook returns `{width: 0, height: 0, left: 0, top: 0}` until the first layout pass completes. It also returns zeros when the tracked ref is detached. ### useStderr() A React hook that returns the stderr stream and stderr-related utilities. #### stderr Type: `stream.Writable`\ Default: `process.stderr` Stderr stream. ```js import {useStderr} from 'ink'; const Example = () => { const {stderr} = useStderr(); return … }; ``` #### write(data) Write any string to stderr while preserving Ink's output. It's useful when you want to display external information outside of Ink's rendering and ensure there's no conflict between the two. It's similar to ``, except it can't accept components; it only works with strings. ##### data Type: `string` Data to write to stderr. ```js import {useStderr} from 'ink'; const Example = () => { const {write} = useStderr(); useEffect(() => { // Write a single message to stderr, above Ink's output write('Hello from Ink to stderr\n'); }, []); return … }; ``` ### useWindowSize() A React hook that returns the current terminal dimensions and re-renders the component whenever the terminal is resized. ```js import {Text, useWindowSize} from 'ink'; const Example = () => { const {columns, rows} = useWindowSize(); return {columns}x{rows}; }; ``` #### columns Type: `number` Number of columns (horizontal character cells). #### rows Type: `number` Number of rows (vertical character cells). ### useFocus(options?) A React hook that returns focus state and focus controls for the current component. A component that uses the `useFocus` hook becomes "focusable" to Ink, so when the user presses Tab, Ink will switch focus to this component. If there are multiple components that execute the `useFocus` hook, focus will be given to them in the order in which these components are rendered. This hook returns an object with an `isFocused` boolean property, which determines whether this component is focused. #### options ##### autoFocus Type: `boolean`\ Default: `false` Auto-focus this component if there's no active (focused) component right now. ##### isActive Type: `boolean`\ Default: `true` Enable or disable this component's focus, while still maintaining its position in the list of focusable components. This is useful for inputs that are temporarily disabled. ##### id Type: `string`\ Required: `false` Set a component's focus ID, which can be used to programmatically focus the component. This is useful for large interfaces with many focusable elements to avoid having to cycle through all of them. ```jsx import {render, useFocus, Text} from 'ink'; const Example = () => { const {isFocused} = useFocus(); return {isFocused ? 'I am focused' : 'I am not focused'}; }; render(); ``` See example in [examples/use-focus](examples/use-focus/use-focus.tsx) and [examples/use-focus-with-id](examples/use-focus-with-id/use-focus-with-id.tsx). ### useFocusManager() A React hook that returns methods to manage focus across focusable components. #### enableFocus() Enable focus management for all components. > [!NOTE] > You don't need to call this method manually unless you've disabled focus management. Focus management is enabled by default. ```js import {useFocusManager} from 'ink'; const Example = () => { const {enableFocus} = useFocusManager(); useEffect(() => { enableFocus(); }, []); return … }; ``` #### disableFocus() Disable focus management for all components. The currently active component (if there's one) will lose its focus. ```js import {useFocusManager} from 'ink'; const Example = () => { const {disableFocus} = useFocusManager(); useEffect(() => { disableFocus(); }, []); return … }; ``` #### focusNext() Switch focus to the next focusable component. If there's no active component right now, focus will be given to the first focusable component. If the active component is the last in the list of focusable components, focus will be switched to the first focusable component. > [!NOTE] > Ink calls this method when user presses Tab. ```js import {useFocusManager} from 'ink'; const Example = () => { const {focusNext} = useFocusManager(); useEffect(() => { focusNext(); }, []); return … }; ``` #### focusPrevious() Switch focus to the previous focusable component. If there's no active component right now, focus will be given to the first focusable component. If the active component is the first in the list of focusable components, focus will be switched to the last focusable component. > [!NOTE] > Ink calls this method when user presses Shift+Tab. ```js import {useFocusManager} from 'ink'; const Example = () => { const {focusPrevious} = useFocusManager(); useEffect(() => { focusPrevious(); }, []); return … }; ``` #### focus(id) ##### id Type: `string` Switch focus to the component with the given [`id`](#id). If there's no component with that ID, focus is not changed. ```js import {useFocusManager, useInput} from 'ink'; const Example = () => { const {focus} = useFocusManager(); useInput(input => { if (input === 's') { // Focus the component with focus ID 'someId' focus('someId'); } }); return … }; ``` #### activeId Type: `string | undefined` The ID of the currently focused component, or `undefined` if no component is focused. ```js import {Text, useFocusManager} from 'ink'; const Example = () => { const {activeId} = useFocusManager(); return Focused: {activeId ?? 'none'}; }; ``` ### useCursor() A React hook that returns methods to 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 ( Type here: {prompt}{text} ); }; ``` #### 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() A React hook that returns whether a screen reader is enabled. This is useful when you want to render different output for screen readers. ```jsx import {useIsScreenReaderEnabled, Text} from 'ink'; const Example = () => { const isScreenReaderEnabled = useIsScreenReaderEnabled(); return ( {isScreenReaderEnabled ? 'Screen reader is enabled' : 'Screen reader is disabled'} ); }; ``` ## API #### render(tree, options?) Returns: [`Instance`](#instance) Mount a component and render the output. ##### tree Type: `ReactNode` ##### options Type: `object` ###### stdout Type: `stream.Writable`\ Default: `process.stdout` Output stream where the app will be rendered. ###### stdin Type: `stream.Readable`\ Default: `process.stdin` Input stream where app will listen for input. ###### stderr Type: `stream.Writable`\ Default: `process.stderr` Error stream. ###### exitOnCtrlC Type: `boolean`\ Default: `true` Configure whether Ink should listen for Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in [raw mode](https://nodejs.org/api/tty.html#tty_readstream_setrawmode_mode), because then Ctrl+C is ignored by default and the process is expected to handle it manually. ###### patchConsole Type: `boolean`\ Default: `true` Patch console methods to ensure console output doesn't mix with Ink's output. When any of the `console.*` methods are called (like `console.log()`), Ink intercepts their output, clears the main output, renders output from the console method, and then rerenders the main output again. That way, both are visible and don't overlap each other. Once unmount starts, Ink restores the native console before React cleanup runs. Teardown-time `console.*` output then follows the normal console behavior instead of being rerouted through Ink. This functionality is powered by [patch-console](https://github.com/vadimdemedes/patch-console), so if you need to disable Ink's interception of output but want to build something custom, you can use that. ###### onRender Type: `({renderTime: number}) => void`\ Default: `undefined` Runs the given callback after each render and re-render with render metrics. This callback runs after Ink commits a frame, but it does not wait for `stdout`/`stderr` stream callbacks. To run code after output is flushed, use [`waitUntilRenderFlush()`](#waituntilrenderflush). ###### isScreenReaderEnabled Type: `boolean`\ Default: `process.env['INK_SCREEN_READER'] === 'true'` Enable screen reader support. See [Screen Reader Support](#screen-reader-support). ###### debug Type: `boolean`\ Default: `false` If `true`, each update will be rendered as separate output, without replacing the previous one. ###### maxFps Type: `number`\ Default: `30` Maximum frames per second for render updates. This controls how frequently the UI can update to prevent excessive re-rendering. Higher values allow more frequent updates but may impact performance. Setting it to a lower value may be useful for components that update very frequently, to reduce CPU usage. ###### incrementalRendering Type: `boolean`\ Default: `false` Enable incremental rendering mode which only updates changed lines instead of redrawing the entire output. This can reduce flickering and improve performance for frequently updating UIs. ###### 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(, {concurrent: true}); ``` > [!NOTE] > Concurrent mode changes the timing of renders. Some tests may need to use `act()` to properly await updates. Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change the rendering mode or create a fresh instance. ###### interactive Type: `boolean`\ Default: `true` (`false` if in CI (detected via [`is-in-ci`](https://github.com/sindresorhus/is-in-ci)) or `stdout.isTTY` is falsy) Override automatic interactive mode detection. By default, Ink detects whether the environment is interactive based on CI detection and `stdout.isTTY`. When non-interactive, Ink skips terminal-specific features like ANSI erase sequences, cursor manipulation, synchronized output, resize handling, and kitty keyboard auto-detection. Only the final frame of non-static output is written at unmount. Most users should not need to set this option. Use it when you have your own "interactive" detection logic that differs from the built-in behavior. > [!NOTE] > Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance. ```jsx // Use your own detection logic const isInteractive = myCustomDetection(); render(, {interactive: isInteractive}); ``` ###### alternateScreen Type: `boolean`\ Default: `false` Render the app in the terminal's alternate screen buffer. When enabled, the app renders on a separate screen, and the original terminal content is restored when the app exits. This is the same mechanism used by programs like vim, htop, and less. Note: The terminal's scrollback buffer is not available while in the alternate screen. This is standard terminal behavior; programs like vim use the alternate screen specifically to avoid polluting the user's scrollback history. Ink intentionally treats alternate-screen teardown output as disposable. It does not preserve or replay teardown-time frames, hook writes, or `console.*` output after restoring the primary screen. Only works in interactive mode. Ignored when `interactive` is `false` or in a non-interactive environment (CI, piped stdout). > [!NOTE] > Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance. ```jsx render(, {alternateScreen: true}); ``` ###### 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(, {kittyKeyboard: {mode: 'auto'}}); ``` ```jsx import {render} from 'ink'; render(, { 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`. #### renderToString(tree, options?) Returns: `string` Render a React element to a string synchronously. Unlike `render()`, this function does not write to stdout, does not set up any terminal event listeners, and returns the rendered output as a string. Useful for generating documentation, writing output to files, testing, or any scenario where you need the rendered output as a string without starting a persistent terminal application. ```jsx import {renderToString, Text, Box} from 'ink'; const output = renderToString( Hello World , ); console.log(output); ``` **Notes:** - Terminal-specific hooks (`useInput`, `useStdin`, `useStdout`, `useStderr`, `useWindowSize`, `useApp`, `useFocus`, `useFocusManager`) return default no-op values since there is no terminal session. They will not throw, but they will not function as in a live terminal. - `useEffect` callbacks will execute during rendering (due to synchronous rendering mode), but state updates they trigger will not affect the returned output, which reflects the initial render. - `useLayoutEffect` callbacks fire synchronously during commit, so state updates they trigger **will** be reflected in the output. - The `` component is supported — its output is prepended to the dynamic output. - If a component throws during rendering, the error is propagated to the caller after cleanup. ##### tree Type: `ReactNode` ##### options Type: `object` ###### columns Type: `number`\ Default: `80` Width of the virtual terminal in columns. Controls where text wrapping occurs. ```jsx const output = renderToString({'A'.repeat(100)}, { columns: 40, }); // Text wraps at 40 columns ``` #### Instance This is the object that `render()` returns. ##### rerender(tree) Replace the previous root node with a new one or update the props of the current root node. ###### tree Type: `ReactNode` ```jsx // Update props of the root node const {rerender} = render(); rerender(); // Replace root node const {rerender} = render(); rerender(); ``` ##### unmount() Manually unmount the whole Ink app. ```jsx const {unmount} = render(); unmount(); ``` ##### waitUntilExit() Returns a promise that settles when the app is unmounted. It resolves with the value passed to `exit(value)` and rejects with the error passed to `exit(error)`. When `unmount()` is called manually, it settles after unmount-related stdout writes complete. ```jsx const {unmount, waitUntilExit} = render(); setTimeout(unmount, 1000); await waitUntilExit(); // resolves after `unmount()` is called ``` ##### waitUntilRenderFlush() Returns a promise that settles after pending render output is flushed to stdout. Useful when you need to run code only after a frame is written: ```jsx const {rerender, waitUntilRenderFlush} = render(); rerender(); await waitUntilRenderFlush(); // output for "ready" is flushed runNextCommand(); ``` ##### cleanup() Unmount the current app and delete the internal Ink instance associated with the current `stdout`. This is mostly useful for advanced cases (for example, tests) where you need `render()` to create a fresh instance for the same stream. Unlike deleting the internal instance directly, this also tears down terminal state such as the alternate screen. ##### clear() Clear output. ```jsx const {clear} = render(); clear(); ``` #### measureElement(ref) Measure the dimensions of a particular `` element. Returns an object with `width` and `height` properties. This function is useful when your component needs to know the amount of available space it has. You can use it when you need to change the layout based on the length of its content. > [!NOTE] > `measureElement()` returns `{width: 0, height: 0}` when called during render (before layout is calculated). Call it from post-render code, such as `useEffect`, `useLayoutEffect`, input handlers, or timer callbacks. When content changes, pass the relevant dependency to your effect so it re-measures after each update. ##### ref Type: `MutableRef` A reference to a `` element captured with the `ref` property. See [Refs](https://reactjs.org/docs/refs-and-the-dom.html) for more information on how to capture references. ```jsx import {render, measureElement, Box, Text} from 'ink'; const Example = () => { const ref = useRef(); useEffect(() => { const {width, height} = measureElement(ref.current); // width = 100, height = 1 }, []); return ( This box will stretch to 100 width ); }; render(); ``` ## Testing Ink components are simple to test with [ink-testing-library](https://github.com/vadimdemedes/ink-testing-library). Here's a simple example that checks how the component is rendered: ```jsx import React from 'react'; import {Text} from 'ink'; import {render} from 'ink-testing-library'; const Test = () => Hello World; const {lastFrame} = render(); lastFrame() === 'Hello World'; //=> true ``` Check out [ink-testing-library](https://github.com/vadimdemedes/ink-testing-library) for more examples and full documentation. ## Using React Devtools ![](media/devtools.jpg) Ink supports [React Devtools](https://github.com/facebook/react/tree/master/packages/react-devtools) out of the box. To enable integration with React Devtools in your Ink-based CLI, first ensure you have installed the optional `react-devtools-core` dependency, and then run your app with the `DEV=true` environment variable: ```sh DEV=true my-cli ``` Then, start React Devtools itself: ```sh npx react-devtools ``` After it starts, you should see the component tree of your CLI. You can even inspect and change the props of components, and see the results immediately in the CLI, without restarting it. > [!NOTE] > You must manually quit your CLI via Ctrl+C after you're done testing. ## Screen Reader Support Ink has basic support for screen readers. To enable it, you can either pass the `isScreenReaderEnabled` option to the `render` function or set the `INK_SCREEN_READER` environment variable to `true`. Ink implements a small subset of functionality from the [ARIA specification](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA). ```jsx render(, {isScreenReaderEnabled: true}); ``` When screen reader support is enabled, Ink will try its best to generate a screen-reader-friendly output. For example, for this code: ```jsx Accept terms and conditions ``` Ink will generate the following output for screen readers: ``` (checked) checkbox: Accept terms and conditions ``` You can also provide a custom label for screen readers if you want to render something different for them. For example, if you are building a progress bar, you can use `aria-label` to provide a more descriptive label for screen readers. ```jsx 50% ``` In the example above, the screen reader will read "Progress: 50%" instead of "50%". ### `aria-label` Type: `string` A label for the element for screen readers. ### `aria-hidden` Type: `boolean`\ Default: `false` Hide the element from screen readers. ##### aria-role Type: `string` The role of the element. Supported values: - `button` - `checkbox` - `radio` - `radiogroup` - `list` - `listitem` - `menu` - `menuitem` - `progressbar` - `tab` - `tablist` - `timer` - `toolbar` - `table` ##### aria-state Type: `object` The state of the element. Supported values: - `checked` (boolean) - `disabled` (boolean) - `expanded` (boolean) - `selected` (boolean) ## Creating Components When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. ### General Principles - **Provide screen reader-friendly output:** Use the `useIsScreenReaderEnabled` hook to detect if a screen reader is active. You can then render more descriptive output for screen reader users. - **Leverage ARIA props:** For components that have a specific role (e.g., a checkbox or button), use the `aria-role`, `aria-state`, and `aria-label` props on `` and `` to provide semantic meaning to screen readers. For a practical example of building an accessible component, see the [ARIA example](/examples/aria/aria.tsx). ## Useful Components - [ink-text-input](https://github.com/vadimdemedes/ink-text-input) - Text input. - [ink-spinner](https://github.com/vadimdemedes/ink-spinner) - Spinner. - [ink-select-input](https://github.com/vadimdemedes/ink-select-input) - Select (dropdown) input. - [ink-link](https://github.com/sindresorhus/ink-link) - Link. - [ink-gradient](https://github.com/sindresorhus/ink-gradient) - Gradient color. - [ink-big-text](https://github.com/sindresorhus/ink-big-text) - Awesome text. - [ink-picture](https://github.com/endernoke/ink-picture) - Display images. - [ink-tab](https://github.com/jdeniau/ink-tab) - Tab. - [ink-color-pipe](https://github.com/LitoMore/ink-color-pipe) - Create color text with simpler style strings. - [ink-multi-select](https://github.com/karaggeorge/ink-multi-select) - Select one or more values from a list - [ink-divider](https://github.com/JureSotosek/ink-divider) - A divider. - [ink-progress-bar](https://github.com/brigand/ink-progress-bar) - Progress bar. - [ink-table](https://github.com/maticzav/ink-table) - Table. - [ink-ascii](https://github.com/hexrcs/ink-ascii) - Awesome text component with more font choices, based on Figlet. - [ink-markdown](https://github.com/cameronhunter/ink-markdown) - Render syntax highlighted Markdown. - [ink-quicksearch-input](https://github.com/Eximchain/ink-quicksearch-input) - Select component with fast, quicksearch-like navigation. - [ink-confirm-input](https://github.com/kevva/ink-confirm-input) - Yes/No confirmation input. - [ink-syntax-highlight](https://github.com/vsashyn/ink-syntax-highlight) - Code syntax highlighting. - [ink-form](https://github.com/lukasbach/ink-form) - Form. - [ink-task-list](https://github.com/privatenumber/ink-task-list) - Task list. - [ink-spawn](https://github.com/kraenhansen/ink-spawn) - Spawn child processes. - [ink-titled-box](https://github.com/mishieck/ink-titled-box) - Box with a title. - [ink-chart](https://github.com/pppp606/ink-chart) - Sparkline and bar chart. - [ink-scroll-view](https://github.com/ByteLandTechnology/ink-scroll-view) - Scroll container. - [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. - [ink-color-picker](https://github.com/sina-byn/ink-color-picker) - Color picker. ## Useful Hooks - [ink-use-stdout-dimensions](https://github.com/cameronhunter/ink-monorepo/tree/master/packages/ink-use-stdout-dimensions) - Subscribe to stdout dimensions. ## Recipes - [Routing with React Router](recipes/routing.md) - Navigate between routes using `MemoryRouter`. ## Examples The [`examples`](/examples) directory contains a set of real examples. You can run them with: ```bash npm run example examples/[example name] # e.g. npm run example examples/borders ``` - [Jest](examples/jest/jest.tsx) - Implementation of basic Jest UI. - [Counter](examples/counter/counter.tsx) - A simple counter that increments every 100ms. - [Form with validation](https://github.com/final-form/rff-cli-example) - Manage form state using [Final Form](https://github.com/final-form/final-form#-final-form). - [Borders](examples/borders/borders.tsx) - Add borders to the `` component. - [Suspense](examples/suspense/suspense.tsx) - Use React Suspense. - [Table](examples/table/table.tsx) - Renders a table with multiple columns and rows. - [Focus management](examples/use-focus/use-focus.tsx) - Use the `useFocus` hook to manage focus between components. - [User input](examples/use-input/use-input.tsx) - Listen for user input. - [Write to stdout](examples/use-stdout/use-stdout.tsx) - Write to stdout, bypassing main Ink output. - [Write to stderr](examples/use-stderr/use-stderr.tsx) - Write to stderr, bypassing main Ink output. - [Static](examples/static/static.tsx) - Use the `` component to render permanent output. - [Child process](examples/subprocess-output) - Renders output from a child process. - [Router](examples/router/router.tsx) - Navigate between routes using React Router's `MemoryRouter`. ## Continuous Integration When running on CI (detected via the `CI` environment variable), Ink adapts its rendering: - Only the last frame is rendered on exit, instead of continuously updating the terminal. This is because most CI environments don't support the ANSI escape sequences used to overwrite previous output. - Terminal resize events are not listened to. If your CI environment supports full terminal rendering and you want to opt out of this behavior, set `CI=false`: ```sh CI=false node my-cli.js ``` ## Maintainers - [Vadim Demedes](https://github.com/vadimdemedes) - [Sindre Sorhus](https://github.com/sindresorhus) ================================================ FILE: recipes/routing.md ================================================ # Routing with React Router [React Router](https://reactrouter.com) can be used for routing in Ink apps via its [`MemoryRouter`](https://reactrouter.com/api/declarative-routers/MemoryRouter). Unlike `BrowserRouter`, `MemoryRouter` doesn't rely on the browser's history API, storing the navigation stack in memory instead — which is exactly what a terminal app needs. ```tsx import React from 'react'; import {MemoryRouter, Routes, Route, useNavigate} from 'react-router'; import {render, useInput, Text} from 'ink'; function Home() { const navigate = useNavigate(); useInput((input, key) => { if (key.return) { navigate('/about'); } }); return Home. Press Enter to go to About.; } function About() { const navigate = useNavigate(); useInput((input, key) => { if (key.return) { navigate('/'); } }); return About. Press Enter to go back Home.; } function App() { return ( } /> } /> ); } render(); ``` Things to keep in mind: - `` can't be used in Ink since it renders an `` tag. Use the `useNavigate` hook for all navigation instead. - `MemoryRouter` starts at `"/"` by default. Set the `initialEntries` prop to start at a different route. - Terminal routing is an abstraction for conditional rendering — routes aren't URLs, they're just screen states. See [`examples/router`](/examples/router) for a working example. ================================================ FILE: src/ansi-tokenizer.ts ================================================ const bellCharacter = '\u0007'; const escapeCharacter = '\u001B'; const stringTerminatorCharacter = '\u009C'; const csiCharacter = '\u009B'; const oscCharacter = '\u009D'; const dcsCharacter = '\u0090'; const pmCharacter = '\u009E'; const apcCharacter = '\u009F'; const sosCharacter = '\u0098'; type ControlStringType = 'osc' | 'dcs' | 'pm' | 'apc' | 'sos'; type CsiToken = { readonly type: 'csi'; readonly value: string; readonly parameterString: string; readonly intermediateString: string; readonly finalCharacter: string; }; type EscToken = { readonly type: 'esc'; readonly value: string; readonly intermediateString: string; readonly finalCharacter: string; }; type ControlStringToken = { readonly type: ControlStringType; readonly value: string; }; type TextToken = { readonly type: 'text'; readonly value: string; }; type StToken = { readonly type: 'st'; readonly value: string; }; type C1Token = { readonly type: 'c1'; readonly value: string; }; type InvalidToken = { readonly type: 'invalid'; readonly value: string; }; export type AnsiToken = | TextToken | CsiToken | EscToken | ControlStringToken | StToken | C1Token | InvalidToken; const isCsiParameterCharacter = (character: string): boolean => { const codePoint = character.codePointAt(0); return codePoint !== undefined && codePoint >= 0x30 && codePoint <= 0x3f; }; const isCsiIntermediateCharacter = (character: string): boolean => { const codePoint = character.codePointAt(0); return codePoint !== undefined && codePoint >= 0x20 && codePoint <= 0x2f; }; const isCsiFinalCharacter = (character: string): boolean => { const codePoint = character.codePointAt(0); return codePoint !== undefined && codePoint >= 0x40 && codePoint <= 0x7e; }; const isEscapeIntermediateCharacter = (character: string): boolean => { const codePoint = character.codePointAt(0); return codePoint !== undefined && codePoint >= 0x20 && codePoint <= 0x2f; }; const isEscapeFinalCharacter = (character: string): boolean => { const codePoint = character.codePointAt(0); return codePoint !== undefined && codePoint >= 0x30 && codePoint <= 0x7e; }; const isC1ControlCharacter = (character: string): boolean => { const codePoint = character.codePointAt(0); return codePoint !== undefined && codePoint >= 0x80 && codePoint <= 0x9f; }; // Standards references: // ECMA-48 control functions and CSI byte classes: https://ecma-international.org/publications-and-standards/standards/ecma-48/ // xterm CSI parameter/intermediate/final format notes: https://invisible-island.net/xterm/ecma-48-parameter-format.html // xterm/OSC BEL termination behavior: https://davidrg.github.io/ckwin/dev/ctlseqs.html const readCsiSequence = ( text: string, fromIndex: number, ): | { readonly endIndex: number; readonly parameterString: string; readonly intermediateString: string; readonly finalCharacter: string; } | undefined => { let index = fromIndex; while (index < text.length) { const character = text[index]!; if (!isCsiParameterCharacter(character)) { break; } index++; } const parameterString = text.slice(fromIndex, index); const intermediateStartIndex = index; while (index < text.length) { const character = text[index]!; if (!isCsiIntermediateCharacter(character)) { break; } index++; } const intermediateString = text.slice(intermediateStartIndex, index); const finalCharacter = text[index]; if (finalCharacter === undefined || !isCsiFinalCharacter(finalCharacter)) { return undefined; } return { endIndex: index + 1, parameterString, intermediateString, finalCharacter, }; }; const findControlStringTerminatorIndex = ( text: string, fromIndex: number, allowBellTerminator: boolean, ): number | undefined => { for (let index = fromIndex; index < text.length; index++) { const character = text[index]; if (allowBellTerminator && character === bellCharacter) { return index + 1; } if (character === stringTerminatorCharacter) { return index + 1; } if (character === escapeCharacter) { const followingCharacter = text[index + 1]; // Tmux escapes ESC bytes in payload as ESC ESC. if (followingCharacter === escapeCharacter) { index++; continue; } if (followingCharacter === '\\') { return index + 2; } } } return undefined; }; const readEscapeSequence = ( text: string, fromIndex: number, ): | { readonly endIndex: number; readonly intermediateString: string; readonly finalCharacter: string; } | undefined => { let index = fromIndex; while (index < text.length) { const character = text[index]!; if (!isEscapeIntermediateCharacter(character)) { break; } index++; } const intermediateString = text.slice(fromIndex, index); const finalCharacter = text[index]; if (finalCharacter === undefined || !isEscapeFinalCharacter(finalCharacter)) { return undefined; } return { endIndex: index + 1, intermediateString, finalCharacter, }; }; // Centralize control-string rules so ESC and C1 paths do not diverge. const getControlStringFromEscapeIntroducer = ( character: string, ): | { readonly type: ControlStringType; readonly allowBellTerminator: boolean; } | undefined => { switch (character) { case ']': { return {type: 'osc', allowBellTerminator: true}; } case 'P': { return {type: 'dcs', allowBellTerminator: false}; } case '^': { return {type: 'pm', allowBellTerminator: false}; } case '_': { return {type: 'apc', allowBellTerminator: false}; } case 'X': { return {type: 'sos', allowBellTerminator: false}; } default: { return undefined; } } }; const getControlStringFromC1Introducer = ( character: string, ): | { readonly type: ControlStringType; readonly allowBellTerminator: boolean; } | undefined => { switch (character) { case oscCharacter: { return {type: 'osc', allowBellTerminator: true}; } case dcsCharacter: { return {type: 'dcs', allowBellTerminator: false}; } case pmCharacter: { return {type: 'pm', allowBellTerminator: false}; } case apcCharacter: { return {type: 'apc', allowBellTerminator: false}; } case sosCharacter: { return {type: 'sos', allowBellTerminator: false}; } default: { return undefined; } } }; export const hasAnsiControlCharacters = (text: string): boolean => { if (text.includes(escapeCharacter)) { return true; } for (const character of text) { if (isC1ControlCharacter(character)) { return true; } } return false; }; const malformedFromIndex = ( tokens: AnsiToken[], text: string, textStartIndex: number, fromIndex: number, ): AnsiToken[] => { if (fromIndex > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, fromIndex)}); } // Treat the remainder as invalid so callers can drop it as one unsafe unit. tokens.push({type: 'invalid', value: text.slice(fromIndex)}); return tokens; }; export const tokenizeAnsi = (text: string): AnsiToken[] => { if (!hasAnsiControlCharacters(text)) { return [{type: 'text', value: text}]; } const tokens: AnsiToken[] = []; let textStartIndex = 0; for (let index = 0; index < text.length; ) { const character = text[index]; if (character === undefined) { break; } if (character === escapeCharacter) { const followingCharacter = text[index + 1]; if (followingCharacter === undefined) { return malformedFromIndex(tokens, text, textStartIndex, index); } if (followingCharacter === '[') { const csiSequence = readCsiSequence(text, index + 2); if (csiSequence === undefined) { return malformedFromIndex(tokens, text, textStartIndex, index); } if (index > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, index)}); } tokens.push({ type: 'csi', value: text.slice(index, csiSequence.endIndex), parameterString: csiSequence.parameterString, intermediateString: csiSequence.intermediateString, finalCharacter: csiSequence.finalCharacter, }); index = csiSequence.endIndex; textStartIndex = index; continue; } const escapeControlString = getControlStringFromEscapeIntroducer(followingCharacter); if (escapeControlString !== undefined) { const controlStringTerminatorIndex = findControlStringTerminatorIndex( text, index + 2, escapeControlString.allowBellTerminator, ); if (controlStringTerminatorIndex === undefined) { return malformedFromIndex(tokens, text, textStartIndex, index); } if (index > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, index)}); } tokens.push({ type: escapeControlString.type, value: text.slice(index, controlStringTerminatorIndex), }); index = controlStringTerminatorIndex; textStartIndex = index; continue; } const escapeSequence = readEscapeSequence(text, index + 1); if (escapeSequence === undefined) { // Incomplete escape sequences with intermediates are malformed control strings. if (isEscapeIntermediateCharacter(followingCharacter)) { return malformedFromIndex(tokens, text, textStartIndex, index); } if (index > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, index)}); } // Ignore lone ESC and continue tokenizing the rest. index++; textStartIndex = index; continue; } if (index > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, index)}); } tokens.push({ type: 'esc', value: text.slice(index, escapeSequence.endIndex), intermediateString: escapeSequence.intermediateString, finalCharacter: escapeSequence.finalCharacter, }); index = escapeSequence.endIndex; textStartIndex = index; continue; } if (character === csiCharacter) { const csiSequence = readCsiSequence(text, index + 1); if (csiSequence === undefined) { return malformedFromIndex(tokens, text, textStartIndex, index); } if (index > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, index)}); } tokens.push({ type: 'csi', value: text.slice(index, csiSequence.endIndex), parameterString: csiSequence.parameterString, intermediateString: csiSequence.intermediateString, finalCharacter: csiSequence.finalCharacter, }); index = csiSequence.endIndex; textStartIndex = index; continue; } const c1ControlString = getControlStringFromC1Introducer(character); if (c1ControlString !== undefined) { const controlStringTerminatorIndex = findControlStringTerminatorIndex( text, index + 1, c1ControlString.allowBellTerminator, ); if (controlStringTerminatorIndex === undefined) { return malformedFromIndex(tokens, text, textStartIndex, index); } if (index > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, index)}); } tokens.push({ type: c1ControlString.type, value: text.slice(index, controlStringTerminatorIndex), }); index = controlStringTerminatorIndex; textStartIndex = index; continue; } if (character === stringTerminatorCharacter) { if (index > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, index)}); } tokens.push({type: 'st', value: character}); index++; textStartIndex = index; continue; } // Strip remaining C1 controls as standalone functions. if (isC1ControlCharacter(character)) { if (index > textStartIndex) { tokens.push({type: 'text', value: text.slice(textStartIndex, index)}); } tokens.push({type: 'c1', value: character}); index++; textStartIndex = index; continue; } index++; } if (textStartIndex < text.length) { tokens.push({type: 'text', value: text.slice(textStartIndex)}); } return tokens; }; ================================================ FILE: src/colorize.ts ================================================ import chalk, {type ForegroundColorName, type BackgroundColorName} from 'chalk'; type ColorType = 'foreground' | 'background'; const rgbRegex = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/; const ansiRegex = /^ansi256\(\s?(\d+)\s?\)$/; const isNamedColor = (color: string): color is ForegroundColorName => { return color in chalk; }; const colorize = ( str: string, color: string | undefined, type: ColorType, ): string => { if (!color) { return str; } if (isNamedColor(color)) { if (type === 'foreground') { return chalk[color](str); } const methodName = `bg${ color[0]!.toUpperCase() + color.slice(1) }` as BackgroundColorName; return chalk[methodName](str); } if (color.startsWith('#')) { return type === 'foreground' ? chalk.hex(color)(str) : chalk.bgHex(color)(str); } if (color.startsWith('ansi256')) { const matches = ansiRegex.exec(color); if (!matches) { return str; } const value = Number(matches[1]); return type === 'foreground' ? chalk.ansi256(value)(str) : chalk.bgAnsi256(value)(str); } if (color.startsWith('rgb')) { const matches = rgbRegex.exec(color); if (!matches) { return str; } const firstValue = Number(matches[1]); const secondValue = Number(matches[2]); const thirdValue = Number(matches[3]); return type === 'foreground' ? chalk.rgb(firstValue, secondValue, thirdValue)(str) : chalk.bgRgb(firstValue, secondValue, thirdValue)(str); } return str; }; export default colorize; ================================================ FILE: src/components/AccessibilityContext.ts ================================================ import {createContext} from 'react'; export const accessibilityContext = createContext({ isScreenReaderEnabled: false, }); ================================================ FILE: src/components/App.tsx ================================================ import {EventEmitter} from 'node:events'; import process from 'node:process'; import React, { type ReactNode, useState, useRef, useCallback, useMemo, useEffect, } from 'react'; import cliCursor from 'cli-cursor'; import {type CursorPosition} from '../log-update.js'; import {createInputParser} from '../input-parser.js'; import AppContext from './AppContext.js'; import StdinContext from './StdinContext.js'; import StdoutContext from './StdoutContext.js'; import StderrContext from './StderrContext.js'; import FocusContext from './FocusContext.js'; import CursorContext from './CursorContext.js'; import ErrorBoundary from './ErrorBoundary.js'; const tab = '\t'; const shiftTab = '\u001B[Z'; const escape = '\u001B'; type Props = { readonly children: ReactNode; readonly stdin: NodeJS.ReadStream; readonly stdout: NodeJS.WriteStream; readonly stderr: NodeJS.WriteStream; readonly writeToStdout: (data: string) => void; readonly writeToStderr: (data: string) => void; readonly exitOnCtrlC: boolean; readonly onExit: (errorOrResult?: unknown) => void; readonly onWaitUntilRenderFlush: () => Promise; readonly setCursorPosition: (position: CursorPosition | undefined) => void; readonly interactive: boolean; }; type Focusable = { readonly id: string; readonly isActive: boolean; }; // Root component for all Ink apps // It renders stdin and stdout contexts, so that children can access them if needed // It also handles Ctrl+C exiting and cursor visibility function App({ children, stdin, stdout, stderr, writeToStdout, writeToStderr, exitOnCtrlC, onExit, onWaitUntilRenderFlush, setCursorPosition, interactive, }: Props): React.ReactNode { 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 const rawModeEnabledCount = useRef(0); // Count how many components enabled bracketed paste mode const bracketedPasteModeEnabledCount = useRef(0); // eslint-disable-next-line @typescript-eslint/naming-convention 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<(() => void) | undefined>(undefined); const inputParserRef = useRef(createInputParser()); const pendingInputFlushRef = useRef(undefined); // Small delay to let chunked escape sequences complete before flushing as literal input. const pendingInputFlushDelayMilliseconds = 20; const clearPendingInputFlush = useCallback((): void => { if (!pendingInputFlushRef.current) { return; } clearTimeout(pendingInputFlushRef.current); pendingInputFlushRef.current = undefined; }, []); // Determines if TTY is supported on the provided stdin const isRawModeSupported = stdin.isTTY; const detachReadableListener = useCallback((): void => { if (!readableListenerRef.current) { return; } stdin.removeListener('readable', readableListenerRef.current); readableListenerRef.current = undefined; }, [stdin]); const disableRawMode = useCallback((): void => { stdin.setRawMode(false); detachReadableListener(); stdin.unref(); rawModeEnabledCount.current = 0; inputParserRef.current.reset(); clearPendingInputFlush(); }, [stdin, detachReadableListener, clearPendingInputFlush]); const handleExit = useCallback( (errorOrResult?: unknown): void => { if (isRawModeSupported && rawModeEnabledCount.current > 0) { disableRawMode(); } onExit(errorOrResult); }, [isRawModeSupported, disableRawMode, onExit], ); const handleInput = useCallback( (input: string): void => { // 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 emitInput = useCallback( (input: string): void => { handleInput(input); internal_eventEmitter.current.emit('input', input); }, [handleInput], ); const schedulePendingInputFlush = useCallback((): void => { clearPendingInputFlush(); pendingInputFlushRef.current = setTimeout(() => { pendingInputFlushRef.current = undefined; const pendingEscape = inputParserRef.current.flushPendingEscape(); if (!pendingEscape) { return; } emitInput(pendingEscape); }, pendingInputFlushDelayMilliseconds); }, [clearPendingInputFlush, emitInput]); const handleReadable = useCallback((): void => { clearPendingInputFlush(); let chunk; // eslint-disable-next-line @typescript-eslint/no-restricted-types while ((chunk = stdin.read() as string | null) !== null) { const inputEvents = inputParserRef.current.push(chunk); for (const event of inputEvents) { if (typeof event === 'string') { emitInput(event); } else { // Keep paste on a separate channel from `useInput` so key handlers // don't need to branch on mixed key-vs-paste event shapes. if (internal_eventEmitter.current.listenerCount('paste') === 0) { emitInput(event.paste); continue; } internal_eventEmitter.current.emit('paste', event.paste); } } } if (inputParserRef.current.hasPendingEscape()) { schedulePendingInputFlush(); } }, [stdin, emitInput, clearPendingInputFlush, schedulePendingInputFlush]); const handleSetRawMode = useCallback( (isEnabled: boolean): void => { if (!isRawModeSupported) { if (stdin === process.stdin) { 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', ); } else { throw new Error( 'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', ); } } stdin.setEncoding('utf8'); if (isEnabled) { // Ensure raw mode is enabled only once if (rawModeEnabledCount.current === 0) { stdin.ref(); stdin.setRawMode(true); // Store the listener reference to avoid stale closure when removing readableListenerRef.current = handleReadable; stdin.addListener('readable', handleReadable); } rawModeEnabledCount.current++; return; } // Disable raw mode only when no components left that are using it if (rawModeEnabledCount.current === 0) { return; } if (--rawModeEnabledCount.current === 0) { disableRawMode(); } }, [isRawModeSupported, stdin, handleReadable, disableRawMode], ); const handleSetBracketedPasteMode = useCallback( (isEnabled: boolean): void => { if (!stdout.isTTY) { return; } if (isEnabled) { if (bracketedPasteModeEnabledCount.current === 0) { stdout.write('\u001B[?2004h'); } bracketedPasteModeEnabledCount.current++; return; } if (bracketedPasteModeEnabledCount.current === 0) { return; } if (--bracketedPasteModeEnabledCount.current === 0) { stdout.write('\u001B[?2004l'); } }, [stdout], ); // Focus navigation helpers const findNextFocusable = useCallback( ( currentFocusables: Focusable[], currentActiveFocusId: string | undefined, ): string | undefined => { 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; } } return undefined; }, [], ); const findPreviousFocusable = useCallback( ( currentFocusables: Focusable[], currentActiveFocusId: string | undefined, ): string | undefined => { 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; } } return undefined; }, [], ); const focusNext = useCallback((): void => { setFocusables(currentFocusables => { setActiveFocusId(currentActiveFocusId => { const firstFocusableId = currentFocusables.find( focusable => focusable.isActive, )?.id; const nextFocusableId = findNextFocusable( currentFocusables, currentActiveFocusId, ); return nextFocusableId ?? firstFocusableId; }); return currentFocusables; }); }, [findNextFocusable]); const focusPrevious = useCallback((): void => { 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: string): void => { if (!isFocusEnabled || focusablesCountRef.current === 0) return; if (input === tab) { focusNext(); } if (input === shiftTab) { focusPrevious(); } }; internal_eventEmitter.current.on('input', handleTabNavigation); const emitter = internal_eventEmitter.current; return () => { emitter.off('input', handleTabNavigation); }; }, [isFocusEnabled, focusNext, focusPrevious]); const enableFocus = useCallback((): void => { setIsFocusEnabled(true); }, []); const disableFocus = useCallback((): void => { setIsFocusEnabled(false); }, []); const focus = useCallback((id: string): void => { setFocusables(currentFocusables => { const hasFocusableId = currentFocusables.some( focusable => focusable?.id === id, ); if (hasFocusableId) { setActiveFocusId(id); } return currentFocusables; }); }, []); const addFocusable = useCallback( (id: string, {autoFocus}: {autoFocus: boolean}): void => { setFocusables(currentFocusables => { focusablesCountRef.current = currentFocusables.length + 1; return [ ...currentFocusables, { id, isActive: true, }, ]; }); if (autoFocus) { setActiveFocusId(currentActiveFocusId => { if (!currentActiveFocusId) { return id; } return currentActiveFocusId; }); } }, [], ); const removeFocusable = useCallback((id: string): void => { setActiveFocusId(currentActiveFocusId => { if (currentActiveFocusId === id) { return undefined; } return currentActiveFocusId; }); setFocusables(currentFocusables => { const filtered = currentFocusables.filter(focusable => { return focusable.id !== id; }); focusablesCountRef.current = filtered.length; return filtered; }); }, []); const activateFocusable = useCallback((id: string): void => { setFocusables(currentFocusables => currentFocusables.map(focusable => { if (focusable.id !== id) { return focusable; } return { id, isActive: true, }; }), ); }, []); const deactivateFocusable = useCallback((id: string): void => { setActiveFocusId(currentActiveFocusId => { if (currentActiveFocusId === id) { return undefined; } return currentActiveFocusId; }); setFocusables(currentFocusables => currentFocusables.map(focusable => { if (focusable.id !== id) { return focusable; } return { id, isActive: false, }; }), ); }, []); // Handle cursor visibility, raw mode, and bracketed paste mode cleanup on unmount useEffect(() => { return () => { const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded; if (interactive && canWriteToStdout) { cliCursor.show(stdout); } if (isRawModeSupported && rawModeEnabledCount.current > 0) { disableRawMode(); } if (bracketedPasteModeEnabledCount.current > 0) { if (stdout.isTTY && canWriteToStdout) { stdout.write('\u001B[?2004l'); } bracketedPasteModeEnabledCount.current = 0; } }; }, [stdout, isRawModeSupported, disableRawMode, interactive]); // Memoize context values to prevent unnecessary re-renders const appContextValue = useMemo( () => ({ exit: handleExit, waitUntilRenderFlush: onWaitUntilRenderFlush, }), [handleExit, onWaitUntilRenderFlush], ); const stdinContextValue = useMemo( () => ({ stdin, setRawMode: handleSetRawMode, setBracketedPasteMode: handleSetBracketedPasteMode, 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, handleSetBracketedPasteMode, 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 ( {children} ); } App.displayName = 'InternalApp'; export default App; ================================================ FILE: src/components/AppContext.ts ================================================ import {createContext} from 'react'; export type Props = { /** Exit (unmount) the whole Ink app. - `exit()` — resolves `waitUntilExit()` with `undefined`. - `exit(new Error('…'))` — rejects `waitUntilExit()` with the error. - `exit(value)` — resolves `waitUntilExit()` with `value`. */ readonly exit: (errorOrResult?: Error | unknown) => void; /** Returns a promise that settles after pending render output is flushed to stdout. @example ```jsx import {useEffect} from 'react'; import {useApp} from 'ink'; const Example = () => { const {waitUntilRenderFlush} = useApp(); useEffect(() => { void (async () => { await waitUntilRenderFlush(); runNextCommand(); })(); }, [waitUntilRenderFlush]); return …; }; ``` */ readonly waitUntilRenderFlush: () => Promise; }; /** `AppContext` is a React context that exposes lifecycle methods for the app. */ // eslint-disable-next-line @typescript-eslint/naming-convention const AppContext = createContext({ exit() {}, async waitUntilRenderFlush() {}, }); AppContext.displayName = 'InternalAppContext'; export default AppContext; ================================================ FILE: src/components/BackgroundContext.ts ================================================ import {createContext} from 'react'; import {type LiteralUnion} from 'type-fest'; import {type ForegroundColorName} from 'ansi-styles'; export type BackgroundColor = LiteralUnion; export const backgroundContext = createContext( undefined, ); ================================================ FILE: src/components/Box.tsx ================================================ import React, {forwardRef, useContext, type PropsWithChildren} from 'react'; import {type Except} from 'type-fest'; import {type Styles} from '../styles.js'; import {type DOMElement} from '../dom.js'; import {accessibilityContext} from './AccessibilityContext.js'; import {backgroundContext} from './BackgroundContext.js'; export type Props = Except & { /** A label for the element for screen readers. */ readonly 'aria-label'?: string; /** Hide the element from screen readers. */ readonly 'aria-hidden'?: boolean; /** The role of the element. */ readonly 'aria-role'?: | 'button' | 'checkbox' | 'combobox' | 'list' | 'listbox' | 'listitem' | 'menu' | 'menuitem' | 'option' | 'progressbar' | 'radio' | 'radiogroup' | 'tab' | 'tablist' | 'table' | 'textbox' | 'timer' | 'toolbar'; /** The state of the element. */ readonly 'aria-state'?: { readonly busy?: boolean; readonly checked?: boolean; readonly disabled?: boolean; readonly expanded?: boolean; readonly multiline?: boolean; readonly multiselectable?: boolean; readonly readonly?: boolean; readonly required?: boolean; readonly selected?: boolean; }; }; /** `` is an essential Ink component to build your layout. It's like `
` in the browser. */ const Box = forwardRef>( ( { children, backgroundColor, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, 'aria-role': role, 'aria-state': ariaState, ...style }, ref, ) => { const {isScreenReaderEnabled} = useContext(accessibilityContext); const label = ariaLabel ? {ariaLabel} : undefined; if (isScreenReaderEnabled && ariaHidden) { return null; } const boxElement = ( {isScreenReaderEnabled && label ? label : children} ); // If this Box has a background color, provide it to children via context if (backgroundColor) { return ( {boxElement} ); } return boxElement; }, ); Box.displayName = 'Box'; export default Box; ================================================ FILE: src/components/CursorContext.ts ================================================ import {createContext} from 'react'; 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; }; // eslint-disable-next-line @typescript-eslint/naming-convention const CursorContext = createContext({ setCursorPosition() {}, }); CursorContext.displayName = 'InternalCursorContext'; export default CursorContext; ================================================ FILE: src/components/ErrorBoundary.tsx ================================================ import React, {PureComponent, type ReactNode} from 'react'; import ErrorOverview from './ErrorOverview.js'; type Props = { readonly children: ReactNode; readonly onError: (error: Error) => void; }; type State = { readonly error?: Error; }; // 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: Error) { return {error}; } override state: State = { error: undefined, }; override componentDidCatch(error: Error): void { this.props.onError(error); } override render(): ReactNode { if (this.state.error) { return ; } return this.props.children; } } ================================================ FILE: src/components/ErrorOverview.tsx ================================================ import * as fs from 'node:fs'; import {cwd} from 'node:process'; import React from 'react'; import StackUtils from 'stack-utils'; import codeExcerpt, {type CodeExcerpt} from 'code-excerpt'; import Box from './Box.js'; import Text from './Text.js'; // Error's source file is reported as file:///home/user/file.js // This function removes the file://[cwd] part const cleanupPath = (path: string | undefined): string | undefined => { return path?.replace(`file://${cwd()}/`, ''); }; const stackUtils = new StackUtils({ cwd: cwd(), internals: StackUtils.nodeInternals(), }); type Props = { readonly error: Error; }; export default function ErrorOverview({error}: Props) { const stack = error.stack ? error.stack.split('\n').slice(1) : undefined; const origin = stack ? stackUtils.parseLine(stack[0]!) : undefined; const filePath = cleanupPath(origin?.file); let excerpt: CodeExcerpt[] | undefined; let lineWidth = 0; if (filePath && origin?.line && fs.existsSync(filePath)) { const sourceCode = fs.readFileSync(filePath, 'utf8'); excerpt = codeExcerpt(sourceCode, origin.line); if (excerpt) { for (const {line} of excerpt) { lineWidth = Math.max(lineWidth, String(line).length); } } } return ( {' '} ERROR{' '} {error.message} {origin && filePath ? ( {filePath}:{origin.line}:{origin.column} ) : null} {origin && excerpt ? ( {excerpt.map(({line, value}) => ( {String(line).padStart(lineWidth, ' ')}: {' ' + value} ))} ) : null} {error.stack ? ( {error.stack .split('\n') .slice(1) .map(line => { const parsedLine = stackUtils.parseLine(line); // If the line from the stack cannot be parsed, we print out the unparsed line. if (!parsedLine) { return ( - {line} \t{' '} ); } return ( - {parsedLine.function} {' '} ({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}: {parsedLine.column}) ); })} ) : null} ); } ================================================ FILE: src/components/FocusContext.ts ================================================ import {createContext} from 'react'; export type Props = { readonly activeId?: string; readonly add: (id: string, options: {autoFocus: boolean}) => void; readonly remove: (id: string) => void; readonly activate: (id: string) => void; readonly deactivate: (id: string) => void; readonly enableFocus: () => void; readonly disableFocus: () => void; readonly focusNext: () => void; readonly focusPrevious: () => void; readonly focus: (id: string) => void; }; // eslint-disable-next-line @typescript-eslint/naming-convention const FocusContext = createContext({ activeId: undefined, add() {}, remove() {}, activate() {}, deactivate() {}, enableFocus() {}, disableFocus() {}, focusNext() {}, focusPrevious() {}, focus() {}, }); FocusContext.displayName = 'InternalFocusContext'; export default FocusContext; ================================================ FILE: src/components/Newline.tsx ================================================ import React from 'react'; export type Props = { /** Number of newlines to insert. @default 1 */ readonly count?: number; }; /** Adds one or more newline (`\n`) characters. Must be used within `` components. */ export default function Newline({count = 1}: Props) { return {'\n'.repeat(count)}; } ================================================ FILE: src/components/Spacer.tsx ================================================ import React from 'react'; import Box from './Box.js'; /** A flexible space that expands along the major axis of its containing layout. It's useful as a shortcut for filling all the available space between elements. */ export default function Spacer() { return ; } ================================================ FILE: src/components/Static.tsx ================================================ import React, {useMemo, useState, useLayoutEffect, type ReactNode} from 'react'; import {type Styles} from '../styles.js'; export type Props = { /** Array of items of any type to render using the function you pass as a component child. */ readonly items: T[]; /** Styles to apply to a container of child elements. See for supported properties. */ readonly style?: Styles; /** Function that is called to render every item in the `items` array. The first argument is the item itself, and the second argument is the index of that item in the `items` array. Note that a `key` must be assigned to the root component. */ readonly children: (item: T, index: number) => ReactNode; }; /** `` component permanently renders its output above everything else. It's useful for displaying activity like completed tasks or logs—things that don't change after they're rendered (hence the name "Static"). It's preferred to use `` for use cases like these when you can't know or control the number of items that need to be rendered. For example, [Tap](https://github.com/tapjs/node-tap) uses `` to display a list of completed tests. [Gatsby](https://github.com/gatsbyjs/gatsby) uses it to display a list of generated pages while still displaying a live progress bar. */ export default function Static(props: Props) { const {items, children: render, style: customStyle} = props; const [index, setIndex] = useState(0); const itemsToRender: T[] = useMemo(() => { return items.slice(index); }, [items, index]); useLayoutEffect(() => { setIndex(items.length); }, [items.length]); const children = itemsToRender.map((item, itemIndex): ReactNode => { return render(item, index + itemIndex); }); const style: Styles = useMemo( () => ({ position: 'absolute', flexDirection: 'column', ...customStyle, }), [customStyle], ); return ( {children} ); } ================================================ FILE: src/components/StderrContext.ts ================================================ import process from 'node:process'; import {createContext} from 'react'; export type Props = { /** Stderr stream passed to `render()` in `options.stderr` or `process.stderr` by default. */ readonly stderr: NodeJS.WriteStream; /** Write any string to stderr while preserving Ink's output. It's useful when you want to display external information outside of Ink's rendering and ensure there's no conflict between the two. It's similar to ``, except it can't accept components; it only works with strings. */ readonly write: (data: string) => void; }; /** `StderrContext` is a React context that exposes the stderr stream. */ // eslint-disable-next-line @typescript-eslint/naming-convention const StderrContext = createContext({ stderr: process.stderr, write() {}, }); StderrContext.displayName = 'InternalStderrContext'; export default StderrContext; ================================================ FILE: src/components/StdinContext.ts ================================================ import {EventEmitter} from 'node:events'; import process from 'node:process'; import {createContext} from 'react'; export type PublicProps = { /** The stdin stream passed to `render()` in `options.stdin`, or `process.stdin` by default. Useful if your app needs to handle user input. */ readonly stdin: NodeJS.ReadStream; /** Ink exposes this function via own `` to be able to handle Ctrl+C, that's why you should use Ink's `setRawMode` instead of `process.stdin.setRawMode`. If the `stdin` stream passed to Ink does not support setRawMode, this function does nothing. */ readonly setRawMode: (value: boolean) => void; /** A boolean flag determining if the current `stdin` supports `setRawMode`. A component using `setRawMode` might want to use `isRawModeSupported` to nicely fall back in environments where raw mode is not supported. */ readonly isRawModeSupported: boolean; }; export type Props = PublicProps & { /** Enable or disable bracketed paste mode on the terminal. When enabled, pasted text is wrapped in escape sequences that allow it to be distinguished from typed input. */ readonly setBracketedPasteMode: (value: boolean) => void; readonly internal_exitOnCtrlC: boolean; readonly internal_eventEmitter: EventEmitter; }; /** `StdinContext` is a React context that exposes the input stream. */ // eslint-disable-next-line @typescript-eslint/naming-convention const StdinContext = createContext({ stdin: process.stdin, // eslint-disable-next-line @typescript-eslint/naming-convention internal_eventEmitter: new EventEmitter(), setRawMode() {}, setBracketedPasteMode() {}, isRawModeSupported: false, // eslint-disable-next-line @typescript-eslint/naming-convention internal_exitOnCtrlC: true, }); StdinContext.displayName = 'InternalStdinContext'; export default StdinContext; ================================================ FILE: src/components/StdoutContext.ts ================================================ import process from 'node:process'; import {createContext} from 'react'; export type Props = { /** Stdout stream passed to `render()` in `options.stdout` or `process.stdout` by default. */ readonly stdout: NodeJS.WriteStream; /** Write any string to stdout while preserving Ink's output. It's useful when you want to display external information outside of Ink's rendering and ensure there's no conflict between the two. It's similar to ``, except it can't accept components; it only works with strings. */ readonly write: (data: string) => void; }; /** `StdoutContext` is a React context that exposes the stdout stream where Ink renders your app. */ // eslint-disable-next-line @typescript-eslint/naming-convention const StdoutContext = createContext({ stdout: process.stdout, write() {}, }); StdoutContext.displayName = 'InternalStdoutContext'; export default StdoutContext; ================================================ FILE: src/components/Text.tsx ================================================ import React, {useContext, type ReactNode} from 'react'; import chalk, {type ForegroundColorName} from 'chalk'; import {type LiteralUnion} from 'type-fest'; import colorize from '../colorize.js'; import {type Styles} from '../styles.js'; import {accessibilityContext} from './AccessibilityContext.js'; import {backgroundContext} from './BackgroundContext.js'; export type Props = { /** A label for the element for screen readers. */ readonly 'aria-label'?: string; /** Hide the element from screen readers. */ readonly 'aria-hidden'?: boolean; /** Change text color. Ink uses Chalk under the hood, so all its functionality is supported. */ readonly color?: LiteralUnion; /** Same as `color`, but for the background. */ readonly backgroundColor?: LiteralUnion; /** Dim the color (make it less bright). */ readonly dimColor?: boolean; /** Make the text bold. */ readonly bold?: boolean; /** Make the text italic. */ readonly italic?: boolean; /** Make the text underlined. */ readonly underline?: boolean; /** Make the text crossed out with a line. */ readonly strikethrough?: boolean; /** Inverse background and foreground colors. */ readonly inverse?: boolean; /** This property tells Ink to wrap or truncate text if its width is larger than the container. If `wrap` is passed (the default), Ink will wrap text and split it into multiple lines. If `truncate-*` is passed, Ink will truncate text instead, resulting in one line of text with the rest cut off. */ readonly wrap?: Styles['textWrap']; readonly children?: ReactNode; }; /** This component can display text and change its style to make it bold, underlined, italic, or strikethrough. */ export default function Text({ color, backgroundColor, dimColor = false, bold = false, italic = false, underline = false, strikethrough = false, inverse = false, wrap = 'wrap', children, 'aria-label': ariaLabel, 'aria-hidden': ariaHidden = false, }: Props) { const {isScreenReaderEnabled} = useContext(accessibilityContext); const inheritedBackgroundColor = useContext(backgroundContext); const childrenOrAriaLabel = isScreenReaderEnabled && ariaLabel ? ariaLabel : children; if (childrenOrAriaLabel === undefined || childrenOrAriaLabel === null) { return null; } const transform = (children: string): string => { if (dimColor) { children = chalk.dim(children); } if (color) { children = colorize(children, color, 'foreground'); } // Use explicit backgroundColor if provided, otherwise use inherited from parent Box const effectiveBackgroundColor = backgroundColor ?? inheritedBackgroundColor; if (effectiveBackgroundColor) { children = colorize(children, effectiveBackgroundColor, 'background'); } if (bold) { children = chalk.bold(children); } if (italic) { children = chalk.italic(children); } if (underline) { children = chalk.underline(children); } if (strikethrough) { children = chalk.strikethrough(children); } if (inverse) { children = chalk.inverse(children); } return children; }; if (isScreenReaderEnabled && ariaHidden) { return null; } return ( {isScreenReaderEnabled && ariaLabel ? ariaLabel : children} ); } ================================================ FILE: src/components/Transform.tsx ================================================ import React, {useContext, type ReactNode} from 'react'; import {accessibilityContext} from './AccessibilityContext.js'; export type Props = { /** Screen-reader-specific text to output. If this is set, all children will be ignored. */ readonly accessibilityLabel?: string; /** Function that transforms children output. It accepts children and must return transformed children as well. Note that when children use `` styling props (e.g. `color`, `bold`), the string will contain ANSI escape codes. */ readonly transform: (children: string, index: number) => string; readonly children?: ReactNode; }; /** Transform a string representation of React components before they're written to output. For example, you might want to apply a gradient to text, add a clickable link, or create some text effects. These use cases can't accept React nodes as input; they expect a string. That's what the component does: it gives you an output string of its child components and lets you transform it in any way. */ export default function Transform({ children, transform, accessibilityLabel, }: Props) { const {isScreenReaderEnabled} = useContext(accessibilityContext); if (children === undefined || children === null) { return null; } return ( {isScreenReaderEnabled && accessibilityLabel ? accessibilityLabel : children} ); } ================================================ FILE: src/cursor-helpers.ts ================================================ import ansiEscapes from 'ansi-escapes'; export type CursorPosition = { x: number; y: number; }; const showCursorEscape = '\u001B[?25h'; const hideCursorEscape = '\u001B[?25l'; export {showCursorEscape, hideCursorEscape}; /** Compare two cursor positions. Returns true if they differ. */ export const cursorPositionChanged = ( a: CursorPosition | undefined, b: CursorPosition | undefined, ): boolean => 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: number, cursorPosition: CursorPosition | undefined, ): string => { 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: number, previousCursorPosition: CursorPosition | undefined, ): string => { 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) ); }; 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 const buildCursorOnlySequence = (input: CursorOnlyInput): string => { 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: boolean, previousLineCount: number, previousCursorPosition: CursorPosition | undefined, ): string => { if (!cursorWasShown) { return ''; } return ( hideCursorEscape + buildReturnToBottom(previousLineCount, previousCursorPosition) ); }; ================================================ FILE: src/devtools-window-polyfill.ts ================================================ // Ignoring missing types error to avoid adding another dependency for this hack to work import ws from 'ws'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const customGlobal = globalThis as any; // These things must exist before importing `react-devtools-core` // Using ||= intentionally to set falsy values, not just null/undefined // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing customGlobal.WebSocket ||= ws; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing customGlobal.window ||= globalThis; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing customGlobal.self ||= globalThis; // Filter out Ink's internal components from devtools for a cleaner view. // Also, ince `react-devtools-shared` package isn't published on npm, we can't // use its types, that's why there are hard-coded values in `type` fields below. // See https://github.com/facebook/react/blob/edf6eac8a181860fd8a2d076a43806f1237495a1/packages/react-devtools-shared/src/types.js#L24 customGlobal.window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = [ { // ComponentFilterElementType type: 1, // ElementTypeHostComponent value: 7, isEnabled: true, }, { // ComponentFilterDisplayName type: 2, value: 'InternalApp', isEnabled: true, isValid: true, }, { // ComponentFilterDisplayName type: 2, value: 'InternalAppContext', isEnabled: true, isValid: true, }, { // ComponentFilterDisplayName type: 2, value: 'InternalStdoutContext', isEnabled: true, isValid: true, }, { // ComponentFilterDisplayName type: 2, value: 'InternalStderrContext', isEnabled: true, isValid: true, }, { // ComponentFilterDisplayName type: 2, value: 'InternalStdinContext', isEnabled: true, isValid: true, }, { // ComponentFilterDisplayName type: 2, value: 'InternalFocusContext', isEnabled: true, isValid: true, }, ]; ================================================ FILE: src/devtools.ts ================================================ /* eslint-disable import-x/order */ // eslint-disable-next-line import-x/no-unassigned-import import './devtools-window-polyfill.js'; import WebSocket from 'ws'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error import devtools from 'react-devtools-core'; const isDevToolsReachable = async (): Promise => new Promise(resolve => { const socket = new WebSocket('ws://localhost:8097'); const timeout = setTimeout(() => { socket.terminate(); resolve(false); }, 2000); // Don't let the timeout keep the process alive on its own timeout.unref(); socket.on('open', () => { clearTimeout(timeout); socket.terminate(); resolve(true); }); socket.on('error', () => { clearTimeout(timeout); socket.terminate(); resolve(false); }); }); if (await isDevToolsReachable()) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call (devtools as any).initialize(); // eslint-disable-next-line @typescript-eslint/no-unsafe-call (devtools as any).connectToDevTools(); } else { console.warn( 'DEV is set to true, but the React DevTools server is not running. Start it with:\n\n$ npx react-devtools\n', ); } ================================================ FILE: src/dom.ts ================================================ import Yoga, {type Node as YogaNode} from 'yoga-layout'; import measureText from './measure-text.js'; import {type Styles} from './styles.js'; import wrapText from './wrap-text.js'; import squashTextNodes from './squash-text-nodes.js'; import {type OutputTransformer} from './render-node-to-output.js'; type InkNode = { parentNode: DOMElement | undefined; yogaNode?: YogaNode; internal_static?: boolean; style: Styles; }; type LayoutListener = () => void; export type TextName = '#text'; export type ElementNames = | 'ink-root' | 'ink-box' | 'ink-text' | 'ink-virtual-text'; export type NodeNames = ElementNames | TextName; // eslint-disable-next-line @typescript-eslint/naming-convention export type DOMElement = { nodeName: ElementNames; attributes: Record; childNodes: DOMNode[]; internal_transform?: OutputTransformer; internal_accessibility?: { role?: | 'button' | 'checkbox' | 'combobox' | 'list' | 'listbox' | 'listitem' | 'menu' | 'menuitem' | 'option' | 'progressbar' | 'radio' | 'radiogroup' | 'tab' | 'tablist' | 'table' | 'textbox' | 'timer' | 'toolbar'; state?: { busy?: boolean; checked?: boolean; disabled?: boolean; expanded?: boolean; multiline?: boolean; multiselectable?: boolean; readonly?: boolean; required?: boolean; selected?: boolean; }; }; // Internal properties isStaticDirty?: boolean; staticNode?: DOMElement; onComputeLayout?: () => void; onRender?: () => void; onImmediateRender?: () => void; internal_layoutListeners?: Set; } & InkNode; export type TextNode = { nodeName: TextName; nodeValue: string; } & InkNode; // eslint-disable-next-line @typescript-eslint/naming-convention export type DOMNode = T extends { nodeName: infer U; } ? U extends '#text' ? TextNode : DOMElement : never; // eslint-disable-next-line @typescript-eslint/naming-convention export type DOMNodeAttribute = boolean | string | number; export const createNode = (nodeName: ElementNames): DOMElement => { const node: DOMElement = { nodeName, style: {}, attributes: {}, childNodes: [], parentNode: undefined, yogaNode: nodeName === 'ink-virtual-text' ? undefined : Yoga.Node.create(), // eslint-disable-next-line @typescript-eslint/naming-convention internal_accessibility: {}, }; if (nodeName === 'ink-text') { node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node)); } return node; }; export const appendChildNode = ( node: DOMElement, childNode: DOMElement, ): void => { if (childNode.parentNode) { removeChildNode(childNode.parentNode, childNode); } childNode.parentNode = node; node.childNodes.push(childNode); if (childNode.yogaNode) { node.yogaNode?.insertChild( childNode.yogaNode, node.yogaNode.getChildCount(), ); } if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') { markNodeAsDirty(node); } }; export const insertBeforeNode = ( node: DOMElement, newChildNode: DOMNode, beforeChildNode: DOMNode, ): void => { if (newChildNode.parentNode) { removeChildNode(newChildNode.parentNode, newChildNode); } newChildNode.parentNode = node; const index = node.childNodes.indexOf(beforeChildNode); if (index >= 0) { node.childNodes.splice(index, 0, newChildNode); if (newChildNode.yogaNode) { node.yogaNode?.insertChild(newChildNode.yogaNode, index); } } else { node.childNodes.push(newChildNode); if (newChildNode.yogaNode) { node.yogaNode?.insertChild( newChildNode.yogaNode, node.yogaNode.getChildCount(), ); } } if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') { markNodeAsDirty(node); } }; export const removeChildNode = ( node: DOMElement, removeNode: DOMNode, ): void => { if (removeNode.yogaNode) { removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode); } removeNode.parentNode = undefined; const index = node.childNodes.indexOf(removeNode); if (index >= 0) { node.childNodes.splice(index, 1); } if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') { markNodeAsDirty(node); } }; export const setAttribute = ( node: DOMElement, key: string, value: DOMNodeAttribute, ): void => { if (key === 'internal_accessibility') { node.internal_accessibility = value as DOMElement['internal_accessibility']; return; } node.attributes[key] = value; }; export const setStyle = (node: DOMNode, style?: Styles): void => { // Rendering code assumes style is always an object. node.style = style ?? {}; }; export const createTextNode = (text: string): TextNode => { const node: TextNode = { nodeName: '#text', nodeValue: text, yogaNode: undefined, parentNode: undefined, style: {}, }; setTextNodeValue(node, text); return node; }; const measureTextNode = function ( node: DOMNode, width: number, ): {width: number; height: number} { const text = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node); const dimensions = measureText(text); // Text fits into container, no need to wrap if (dimensions.width <= width) { return dimensions; } // This is happening when is shrinking child nodes and Yoga asks // if we can fit this text node in a <1px space, so we just tell Yoga "no" if (dimensions.width >= 1 && width > 0 && width < 1) { return dimensions; } const textWrap = node.style?.textWrap ?? 'wrap'; const wrappedText = wrapText(text, width, textWrap); return measureText(wrappedText); }; const findClosestYogaNode = (node?: DOMNode): YogaNode | undefined => { if (!node?.parentNode) { return undefined; } return node.yogaNode ?? findClosestYogaNode(node.parentNode); }; const markNodeAsDirty = (node?: DOMNode): void => { // Mark closest Yoga node as dirty to measure text dimensions again const yogaNode = findClosestYogaNode(node); yogaNode?.markDirty(); }; export const setTextNodeValue = (node: TextNode, text: string): void => { if (typeof text !== 'string') { text = String(text); } node.nodeValue = text; markNodeAsDirty(node); }; export const addLayoutListener = ( rootNode: DOMElement, listener: LayoutListener, ): (() => void) => { if (rootNode.nodeName !== 'ink-root') { return () => {}; } rootNode.internal_layoutListeners ??= new Set(); rootNode.internal_layoutListeners.add(listener); return () => { rootNode.internal_layoutListeners?.delete(listener); }; }; export const emitLayoutListeners = (rootNode: DOMElement): void => { if (rootNode.nodeName !== 'ink-root' || !rootNode.internal_layoutListeners) { return; } for (const listener of rootNode.internal_layoutListeners) { listener(); } }; ================================================ FILE: src/get-max-width.ts ================================================ import Yoga, {type Node as YogaNode} from 'yoga-layout'; const getMaxWidth = (yogaNode: YogaNode) => { return ( yogaNode.getComputedWidth() - yogaNode.getComputedPadding(Yoga.EDGE_LEFT) - yogaNode.getComputedPadding(Yoga.EDGE_RIGHT) - yogaNode.getComputedBorder(Yoga.EDGE_LEFT) - yogaNode.getComputedBorder(Yoga.EDGE_RIGHT) ); }; export default getMaxWidth; ================================================ FILE: src/global.d.ts ================================================ import {type ReactNode, type Key, type Ref} from 'react'; import {type Except} from 'type-fest'; import {type DOMElement} from './dom.js'; import {type Styles} from './styles.js'; declare module 'react' { namespace JSX { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface IntrinsicElements { 'ink-box': Ink.Box; 'ink-text': Ink.Text; } } } declare namespace Ink { type Box = { internal_static?: boolean; children?: ReactNode; key?: Key; ref?: Ref; style?: Except; internal_accessibility?: DOMElement['internal_accessibility']; }; type Text = { children?: ReactNode; key?: Key; style?: Styles; // eslint-disable-next-line @typescript-eslint/naming-convention internal_transform?: (children: string, index: number) => string; internal_accessibility?: DOMElement['internal_accessibility']; }; } ================================================ FILE: src/hooks/use-app.ts ================================================ import {useContext} from 'react'; import AppContext from '../components/AppContext.js'; /** A React hook that returns app lifecycle methods like `exit()` and `waitUntilRenderFlush()`. */ const useApp = () => useContext(AppContext); export default useApp; ================================================ FILE: src/hooks/use-box-metrics.ts ================================================ import {type RefObject, useState, useEffect, useCallback, useMemo} from 'react'; import {type DOMElement, addLayoutListener} from '../dom.js'; import useStdout from './use-stdout.js'; // Yoga's `right`/`bottom` are omitted: always `0` for flow layout and unintuitive for absolute positioning. /** Metrics of a box element. All positions are relative to the element's parent. */ export type BoxMetrics = { /** Element width. */ readonly width: number; /** Element height. */ readonly height: number; /** Distance from the left edge of the parent. */ readonly left: number; /** Distance from the top edge of the parent. */ readonly top: number; }; export type UseBoxMetricsResult = BoxMetrics & { /** Whether the currently tracked element has been measured in the latest layout pass. */ readonly hasMeasured: boolean; }; const emptyMetrics: BoxMetrics = { width: 0, height: 0, left: 0, top: 0, }; const findRootNode = (node?: DOMElement): DOMElement | undefined => { if (!node) { return undefined; } if (!node.parentNode) { return node.nodeName === 'ink-root' ? node : undefined; } return findRootNode(node.parentNode); }; /** A React hook that returns the current layout metrics for a tracked box element. It updates when layout changes (for example terminal resize, sibling/content changes, or position changes). The hook returns `{width: 0, height: 0, left: 0, top: 0}` until the first layout pass completes. It also returns zeros when the tracked ref is detached. Use `hasMeasured` to detect when the currently tracked element has been measured. @example ```tsx import {useRef} from 'react'; import {Box, Text, useBoxMetrics} from 'ink'; const Example = () => { const ref = useRef(null); const {width, height, left, top, hasMeasured} = useBoxMetrics(ref); return ( {hasMeasured ? `${width}x${height} at ${left},${top}` : 'Measuring...'} ); }; ``` */ const useBoxMetrics = (ref: RefObject): UseBoxMetricsResult => { const [metrics, setMetrics] = useState(emptyMetrics); const [hasMeasured, setHasMeasured] = useState(false); const {stdout} = useStdout(); const updateMetrics = useCallback(() => { const layout = ref.current?.yogaNode?.getComputedLayout() ?? emptyMetrics; setMetrics(previousMetrics => { const hasChanged = previousMetrics.width !== layout.width || previousMetrics.height !== layout.height || previousMetrics.left !== layout.left || previousMetrics.top !== layout.top; return hasChanged ? layout : previousMetrics; }); setHasMeasured(Boolean(ref.current)); }, [ref]); // Runs after every render of this component. // This keeps metrics fresh when local state/props in this subtree change. useEffect(updateMetrics); // Subscribe to root layout commits so memoized components still receive // sibling-driven position/size updates, even when they skip re-rendering. useEffect(() => { const rootNode = findRootNode(ref.current); if (!rootNode) { return; } return addLayoutListener(rootNode, updateMetrics); }); // Terminal resize events do not go through React's render cycle. Ink // recalculates Yoga layout in its own resize handler — registered in the // Ink constructor, before this hook mounts — so by the time the resize // callback runs, Yoga has already computed the post-resize metrics. useEffect(() => { stdout.on('resize', updateMetrics); return () => { stdout.off('resize', updateMetrics); }; }, [stdout, updateMetrics]); return useMemo( () => ({ ...metrics, hasMeasured, }), [metrics, hasMeasured], ); }; export default useBoxMetrics; ================================================ FILE: src/hooks/use-cursor.ts ================================================ import {useContext, useRef, useCallback, useInsertionEffect} from 'react'; import CursorContext from '../components/CursorContext.js'; import {type CursorPosition} from '../log-update.js'; /** A React hook that returns methods to 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: CursorPosition | undefined) => { 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; ================================================ FILE: src/hooks/use-focus-manager.ts ================================================ import {useContext} from 'react'; import FocusContext, {type Props} from '../components/FocusContext.js'; type Output = { /** Enable focus management for all components. */ enableFocus: Props['enableFocus']; /** Disable focus management for all components. The currently active component (if there's one) will lose its focus. */ disableFocus: Props['disableFocus']; /** Switch focus to the next focusable component. If there's no active component right now, focus will be given to the first focusable component. If the active component is the last in the list of focusable components, focus will be switched to the first focusable component. */ focusNext: Props['focusNext']; /** Switch focus to the previous focusable component. If there's no active component right now, focus will be given to the first focusable component. If the active component is the first in the list of focusable components, focus will be switched to the last focusable component. */ focusPrevious: Props['focusPrevious']; /** Switch focus to the element with provided `id`. If there's no element with that `id`, focus is not changed. */ focus: Props['focus']; /** The ID of the currently focused component, or `undefined` if no component is focused. @example ```tsx import {Text, useFocusManager} from 'ink'; const Example = () => { const {activeId} = useFocusManager(); return Focused: {activeId ?? 'none'}; }; ``` */ activeId: Props['activeId']; }; /** A React hook that returns methods to enable or disable focus management for all components or manually switch focus to the next or previous components. */ const useFocusManager = (): Output => { const focusContext = useContext(FocusContext); return { enableFocus: focusContext.enableFocus, disableFocus: focusContext.disableFocus, focusNext: focusContext.focusNext, focusPrevious: focusContext.focusPrevious, focus: focusContext.focus, activeId: focusContext.activeId, }; }; export default useFocusManager; ================================================ FILE: src/hooks/use-focus.ts ================================================ import {useEffect, useContext, useMemo} from 'react'; import FocusContext from '../components/FocusContext.js'; import useStdin from './use-stdin.js'; type Input = { /** Enable or disable this component's focus, while still maintaining its position in the list of focusable components. */ isActive?: boolean; /** Auto-focus this component if there's no active (focused) component right now. */ autoFocus?: boolean; /** Assign an ID to this component, so it can be programmatically focused with `focus(id)`. */ id?: string; }; type Output = { /** Determines whether this component is focused. */ isFocused: boolean; /** Allows focusing a specific element with the provided `id`. */ focus: (id: string) => void; }; /** A React hook that returns focus state and focus controls for the current component. A component that uses the `useFocus` hook becomes "focusable" to Ink, so when the user presses Tab, Ink will switch focus to this component. If there are multiple components that execute the `useFocus` hook, focus will be given to them in the order in which these components are rendered. */ const useFocus = ({ isActive = true, autoFocus = false, id: customId, }: Input = {}): Output => { const {isRawModeSupported, setRawMode} = useStdin(); const {activeId, add, remove, activate, deactivate, focus} = useContext(FocusContext); const id = useMemo(() => { return customId ?? Math.random().toString().slice(2, 7); }, [customId]); useEffect(() => { add(id, {autoFocus}); return () => { remove(id); }; }, [id, autoFocus]); useEffect(() => { if (isActive) { activate(id); } else { deactivate(id); } }, [isActive, id]); useEffect(() => { if (!isRawModeSupported || !isActive) { return; } setRawMode(true); return () => { setRawMode(false); }; }, [isActive]); return { isFocused: Boolean(id) && activeId === id, focus, }; }; export default useFocus; ================================================ FILE: src/hooks/use-input.ts ================================================ import {useEffect} from 'react'; import parseKeypress, {nonAlphanumericKeys} from '../parse-keypress.js'; import reconciler from '../reconciler.js'; import {useStdinContext} from './use-stdin.js'; /** Handy information about a key that was pressed. */ export type Key = { /** Up arrow key was pressed. */ upArrow: boolean; /** Down arrow key was pressed. */ downArrow: boolean; /** Left arrow key was pressed. */ leftArrow: boolean; /** Right arrow key was pressed. */ rightArrow: boolean; /** Page Down key was pressed. */ pageDown: boolean; /** Page Up key was pressed. */ pageUp: boolean; /** Home key was pressed. */ home: boolean; /** End key was pressed. */ end: boolean; /** Return (Enter) key was pressed. */ return: boolean; /** Escape key was pressed. */ escape: boolean; /** Ctrl key was pressed. */ ctrl: boolean; /** Shift key was pressed. */ shift: boolean; /** Tab key was pressed. */ tab: boolean; /** Backspace key was pressed. */ backspace: boolean; /** Delete key was pressed. */ delete: boolean; /** [Meta key](https://en.wikipedia.org/wiki/Meta_key) was pressed. */ 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'; }; type Handler = (input: string, key: Key) => void; type Options = { /** Enable or disable capturing of user input. Useful when there are multiple `useInput` hooks used at once to avoid handling the same input several times. @default true */ isActive?: boolean; }; /** A React hook that returns `void` and handles user input. It's a more convenient alternative to using `StdinContext` and listening for `data` events. The callback you pass to `useInput` is called for each character when the user enters any input. However, if the user pastes text and it's more than one character, the callback will be called only once, and the whole string will be passed as `input`. ``` import {useInput} from 'ink'; const UserInput = () => { useInput((input, key) => { if (input === 'q') { // Exit program } if (key.leftArrow) { // Left arrow key pressed } }); return … }; ``` */ const useInput = (inputHandler: Handler, options: Options = {}) => { // eslint-disable-next-line @typescript-eslint/naming-convention const {stdin, setRawMode, internal_exitOnCtrlC, internal_eventEmitter} = useStdinContext(); useEffect(() => { if (options.isActive === false) { return; } setRawMode(true); return () => { setRawMode(false); }; }, [options.isActive, setRawMode]); useEffect(() => { if (options.isActive === false) { return; } const handleData = (data: string) => { const keypress = parseKeypress(data); const key: Key = { upArrow: keypress.name === 'up', downArrow: keypress.name === 'down', leftArrow: keypress.name === 'left', rightArrow: keypress.name === 'right', pageDown: keypress.name === 'pagedown', pageUp: keypress.name === 'pageup', home: keypress.name === 'home', end: keypress.name === 'end', return: keypress.name === 'return', escape: keypress.name === 'escape', ctrl: keypress.ctrl, shift: keypress.shift, tab: keypress.name === 'tab', backspace: keypress.name === 'backspace', delete: keypress.name === 'delete', // `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false // but with option = true, so we need to take this into account here // to avoid breaking changes in Ink. // TODO(vadimdemedes): consider removing this in the next major version. 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: string; 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 = ''; } // Strip meta if it's still remaining after `parseKeypress` // TODO(vadimdemedes): remove this in the next major version. if (input.startsWith('\u001B')) { input = input.slice(1); } if ( input.length === 1 && typeof input[0] === 'string' && /[A-Z]/.test(input[0]) ) { key.shift = true; } // If app is supposed to exit on Ctrl+C, skip input listeners. if (input === 'c' && key.ctrl && internal_exitOnCtrlC) { return; } // Use discreteUpdates to assign DiscreteEventPriority to state // updates from keyboard input, ensuring they are processed at the // highest priority in concurrent mode. // @ts-expect-error Types require 5 arguments (fn, a, b, c, d) but only fn is needed at runtime. reconciler.discreteUpdates(() => { inputHandler(input, key); }); }; internal_eventEmitter.on('input', handleData); return () => { internal_eventEmitter.removeListener('input', handleData); }; }, [options.isActive, stdin, internal_exitOnCtrlC, inputHandler]); }; export default useInput; ================================================ FILE: src/hooks/use-is-screen-reader-enabled.ts ================================================ import {useContext} from 'react'; import {accessibilityContext} from '../components/AccessibilityContext.js'; /** A React hook that returns whether a screen reader is enabled. This is useful when you want to render different output for screen readers. */ const useIsScreenReaderEnabled = (): boolean => { const {isScreenReaderEnabled} = useContext(accessibilityContext); return isScreenReaderEnabled; }; export default useIsScreenReaderEnabled; ================================================ FILE: src/hooks/use-paste.ts ================================================ import {useEffect} from 'react'; import reconciler from '../reconciler.js'; import {useStdinContext} from './use-stdin.js'; type Options = { /** Enable or disable the paste handler. Useful when multiple components use `usePaste` and only one should be active at a time. @default true */ isActive?: boolean; }; /** A React hook that calls `handler` whenever the user pastes text in the terminal. Bracketed paste mode (`\x1b[?2004h`) is automatically enabled while the hook is active, so pasted text arrives as a single string rather than being misinterpreted as individual key presses. `usePaste` and `useInput` can be used together in the same component. They operate on separate event channels, so paste content is never forwarded to `useInput` handlers when `usePaste` is active. ``` import {useInput, usePaste} from 'ink'; const MyInput = () => { useInput((input, key) => { // Only receives typed characters and key events, not pasted text. if (key.return) { // Submit } }); usePaste((text) => { // Receives the full pasted string, including newlines. console.log('Pasted:', text); }); return … }; ``` */ const usePaste = ( handler: (text: string) => void, options: Options = {}, ): void => { // eslint-disable-next-line @typescript-eslint/naming-convention const {setRawMode, setBracketedPasteMode, internal_eventEmitter} = useStdinContext(); useEffect(() => { if (options.isActive === false) { return; } setRawMode(true); setBracketedPasteMode(true); return () => { setRawMode(false); setBracketedPasteMode(false); }; }, [options.isActive, setRawMode, setBracketedPasteMode]); useEffect(() => { if (options.isActive === false) { return; } const handlePaste = (text: string) => { // Use discreteUpdates to assign DiscreteEventPriority to state // updates triggered by paste, matching the priority of useInput. // @ts-expect-error Types require 5 arguments (fn, a, b, c, d) but only fn is needed at runtime. reconciler.discreteUpdates(() => { handler(text); }); }; internal_eventEmitter.on('paste', handlePaste); return () => { internal_eventEmitter.removeListener('paste', handlePaste); }; }, [options.isActive, internal_eventEmitter, handler]); }; export default usePaste; ================================================ FILE: src/hooks/use-stderr.ts ================================================ import {useContext} from 'react'; import StderrContext from '../components/StderrContext.js'; /** A React hook that returns the stderr stream. */ const useStderr = () => useContext(StderrContext); export default useStderr; ================================================ FILE: src/hooks/use-stdin.ts ================================================ import {useContext} from 'react'; import StdinContext, { type PublicProps, type Props, } from '../components/StdinContext.js'; /** A React hook that returns the stdin stream and stdin-related utilities. */ const useStdin = (): PublicProps => useContext(StdinContext); export const useStdinContext = (): Props => useContext(StdinContext); export default useStdin; ================================================ FILE: src/hooks/use-stdout.ts ================================================ import {useContext} from 'react'; import StdoutContext from '../components/StdoutContext.js'; /** A React hook that returns the stdout stream where Ink renders your app. */ const useStdout = () => useContext(StdoutContext); export default useStdout; ================================================ FILE: src/hooks/use-window-size.ts ================================================ import {useState, useEffect} from 'react'; import {getWindowSize} from '../utils.js'; import useStdout from './use-stdout.js'; /** Dimensions of the terminal window. */ export type WindowSize = { /** Number of columns (horizontal character cells). */ readonly columns: number; /** Number of rows (vertical character cells). */ readonly rows: number; }; /** A React hook that returns the current terminal window dimensions and re-renders the component whenever the terminal is resized. */ const useWindowSize = (): WindowSize => { const {stdout} = useStdout(); const [size, setSize] = useState(() => getWindowSize(stdout)); useEffect(() => { const onResize = () => { setSize(getWindowSize(stdout)); }; stdout.on('resize', onResize); return () => { stdout.off('resize', onResize); }; }, [stdout]); return size; }; export default useWindowSize; ================================================ FILE: src/index.ts ================================================ export type {RenderOptions, Instance} from './render.js'; export {default as render} from './render.js'; export type {RenderToStringOptions} from './render-to-string.js'; export {default as renderToString} from './render-to-string.js'; export type {Props as BoxProps} from './components/Box.js'; export {default as Box} from './components/Box.js'; export type {Props as TextProps} from './components/Text.js'; export {default as Text} from './components/Text.js'; export type {Props as AppProps} from './components/AppContext.js'; export type {PublicProps as StdinProps} from './components/StdinContext.js'; export type {Props as StdoutProps} from './components/StdoutContext.js'; export type {Props as StderrProps} from './components/StderrContext.js'; export type {Props as StaticProps} from './components/Static.js'; export {default as Static} from './components/Static.js'; export type {Props as TransformProps} from './components/Transform.js'; export {default as Transform} from './components/Transform.js'; export type {Props as NewlineProps} from './components/Newline.js'; export {default as Newline} from './components/Newline.js'; export {default as Spacer} from './components/Spacer.js'; export type {Key} from './hooks/use-input.js'; export {default as useInput} from './hooks/use-input.js'; export {default as usePaste} from './hooks/use-paste.js'; export {default as useApp} from './hooks/use-app.js'; export {default as useStdin} from './hooks/use-stdin.js'; export {default as useStdout} from './hooks/use-stdout.js'; export {default as useStderr} from './hooks/use-stderr.js'; export {default as useFocus} from './hooks/use-focus.js'; export {default as useFocusManager} from './hooks/use-focus-manager.js'; export {default as useIsScreenReaderEnabled} from './hooks/use-is-screen-reader-enabled.js'; export {default as useCursor} from './hooks/use-cursor.js'; export type {WindowSize} from './hooks/use-window-size.js'; export {default as useWindowSize} from './hooks/use-window-size.js'; export type {BoxMetrics, UseBoxMetricsResult} from './hooks/use-box-metrics.js'; export {default as useBoxMetrics} from './hooks/use-box-metrics.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'; ================================================ FILE: src/ink.tsx ================================================ import process from 'node:process'; import {Buffer} from 'node:buffer'; import React, {type ReactNode} from 'react'; import {throttle, type DebouncedFunc} from 'es-toolkit/compat'; import ansiEscapes from 'ansi-escapes'; import isInCi from 'is-in-ci'; import autoBind from 'auto-bind'; import signalExit from 'signal-exit'; import patchConsole from 'patch-console'; import {LegacyRoot, ConcurrentRoot} from 'react-reconciler/constants.js'; import {type FiberRoot} from 'react-reconciler'; import Yoga from 'yoga-layout'; import wrapAnsi from 'wrap-ansi'; import {getWindowSize} from './utils.js'; import reconciler from './reconciler.js'; import render from './renderer.js'; import * as dom from './dom.js'; import {hideCursorEscape, showCursorEscape} from './cursor-helpers.js'; import logUpdate, {type LogUpdate, type CursorPosition} 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 { type KittyKeyboardOptions, type KittyFlagName, resolveFlags, } from './kitty-keyboard.js'; const noop = () => {}; const yieldImmediate = async () => new Promise(resolve => { setImmediate(resolve); }); const kittyQueryEscapeByte = 0x1b; const kittyQueryOpenBracketByte = 0x5b; const kittyQueryQuestionMarkByte = 0x3f; const kittyQueryLetterByte = 0x75; const zeroByte = 0x30; const nineByte = 0x39; type KittyQueryResponseMatch = | {state: 'complete'; endIndex: number} | {state: 'partial'}; const isDigitByte = (byte: number): boolean => byte >= zeroByte && byte <= nineByte; const matchKittyQueryResponse = ( buffer: number[], startIndex: number, ): KittyQueryResponseMatch | undefined => { if ( buffer[startIndex] !== kittyQueryEscapeByte || buffer[startIndex + 1] !== kittyQueryOpenBracketByte || buffer[startIndex + 2] !== kittyQueryQuestionMarkByte ) { return undefined; } let index = startIndex + 3; const digitsStartIndex = index; while (index < buffer.length && isDigitByte(buffer[index]!)) { index++; } if (index === digitsStartIndex) { return undefined; } if (index === buffer.length) { return {state: 'partial'}; } if (buffer[index] === kittyQueryLetterByte) { return {state: 'complete', endIndex: index}; } return undefined; }; const hasCompleteKittyQueryResponse = (buffer: number[]): boolean => { for (let index = 0; index < buffer.length; index++) { const match = matchKittyQueryResponse(buffer, index); if (match?.state === 'complete') { return true; } } return false; }; const stripKittyQueryResponsesAndTrailingPartial = ( buffer: number[], ): number[] => { const keptBytes: number[] = []; let index = 0; while (index < buffer.length) { const match = matchKittyQueryResponse(buffer, index); if (match?.state === 'complete') { index = match.endIndex + 1; continue; } if (match?.state === 'partial') { break; } keptBytes.push(buffer[index]!); index++; } return keptBytes; }; const shouldClearTerminalForFrame = ({ isTty, viewportRows, previousOutputHeight, nextOutputHeight, isUnmounting, }: { isTty: boolean; viewportRows: number; previousOutputHeight: number; nextOutputHeight: number; isUnmounting: boolean; }): boolean => { if (!isTty) { return false; } const hadPreviousFrame = previousOutputHeight > 0; const wasFullscreen = previousOutputHeight >= viewportRows; const wasOverflowing = previousOutputHeight > viewportRows; const isOverflowing = nextOutputHeight > viewportRows; const isLeavingFullscreen = wasFullscreen && nextOutputHeight < viewportRows; const shouldClearOnUnmount = isUnmounting && wasFullscreen; return ( // Overflowing frames still need full clear fallback. wasOverflowing || (isOverflowing && hadPreviousFrame) || // Clear when shrinking from fullscreen to non-fullscreen output. isLeavingFullscreen || // Preserve legacy unmount behavior for fullscreen frames: final teardown // render should clear once to avoid leaving a scrolled viewport state. shouldClearOnUnmount ); }; const isErrorInput = (value: unknown): value is Error => { return ( value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' ); }; type MaybeWritableStream = NodeJS.WriteStream & { writable?: boolean; writableEnded?: boolean; destroyed?: boolean; writableLength?: number; _writableState?: unknown; }; const getWritableStreamState = (stdout: MaybeWritableStream) => { const canWriteToStdout = !stdout.destroyed && !stdout.writableEnded && (stdout.writable ?? true); const hasWritableState = stdout._writableState !== undefined || stdout.writableLength !== undefined; return { canWriteToStdout, hasWritableState, }; }; const settleThrottle = ( throttled: unknown, canWriteToStdout: boolean, ): void => { if ( !throttled || typeof (throttled as {flush?: unknown}).flush !== 'function' ) { return; } const throttledValue = throttled as { flush: () => void; cancel?: () => void; }; if (canWriteToStdout) { throttledValue.flush(); } else if (typeof throttledValue.cancel === 'function') { throttledValue.cancel(); } }; /** Performance metrics for a render operation. */ export type RenderMetrics = { /** Time spent rendering in milliseconds. */ renderTime: number; }; export type Options = { stdout: NodeJS.WriteStream; stdin: NodeJS.ReadStream; stderr: NodeJS.WriteStream; debug: boolean; exitOnCtrlC: boolean; patchConsole: boolean; onRender?: (metrics: RenderMetrics) => void; isScreenReaderEnabled?: boolean; waitUntilExit?: () => Promise; maxFps?: number; 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. Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change the rendering mode or create a fresh instance. @default false @experimental */ concurrent?: boolean; kittyKeyboard?: KittyKeyboardOptions; /** Override automatic interactive mode detection. By default, Ink detects whether the environment is interactive based on CI detection (via [`is-in-ci`](https://github.com/sindresorhus/is-in-ci)) and `stdout.isTTY`. Most users should not need to set this. When non-interactive, Ink disables ANSI erase sequences, cursor manipulation, synchronized output, resize handling, and kitty keyboard auto-detection, writing only the final frame at unmount. Set to `false` to force non-interactive mode or `true` to force interactive mode when the automatic detection doesn't suit your use case. Note: Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance. @default true (false if in CI or `stdout.isTTY` is falsy) @see {@link RenderOptions.interactive} */ interactive?: boolean; /** Render the app in the terminal's alternate screen buffer. When enabled, the app renders on a separate screen, and the original terminal content is restored when the app exits. This is the same mechanism used by programs like vim, htop, and less. Note: The terminal's scrollback buffer is not available while in the alternate screen. This is standard terminal behavior; programs like vim use the alternate screen specifically to avoid polluting the user's scrollback history. Note: Ink intentionally treats alternate-screen teardown output as disposable. It does not preserve or replay teardown-time frames, hook writes, or `console.*` output after restoring the primary screen. Only works in interactive mode. Ignored when `interactive` is `false` or in a non-interactive environment (CI, piped stdout). Note: Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance. @default false @see {@link RenderOptions.alternateScreen} */ alternateScreen?: boolean; }; export default class Ink { /** Whether this instance is using concurrent rendering mode. */ readonly isConcurrent: boolean; private readonly options: Options; private readonly log: LogUpdate; private cursorPosition: CursorPosition | undefined; private readonly throttledLog: | LogUpdate | DebouncedFunc<(output: string) => void>; private readonly isScreenReaderEnabled: boolean; private readonly interactive: boolean; private alternateScreen: boolean; // Ignore last render after unmounting a tree to prevent empty output before exit private isUnmounted: boolean; private isUnmounting: boolean; private lastOutput: string; private lastOutputToRender: string; private lastOutputHeight: number; private lastTerminalWidth: number; private readonly container: FiberRoot; private readonly rootNode: dom.DOMElement; // This variable is used only in debug mode to store full static output // so that it's rerendered every time, not just new static parts, like in non-debug mode private fullStaticOutput: string; private readonly exitPromise!: Promise; private exitResult: unknown; private beforeExitHandler?: () => void; private restoreConsole?: () => void; private readonly unsubscribeResize?: () => void; private readonly throttledOnRender?: DebouncedFunc<() => void>; private hasPendingThrottledRender = false; private kittyProtocolEnabled = false; private cancelKittyDetection?: () => void; private nextRenderCommit?: {promise: Promise; resolve: () => void}; constructor(options: Options) { autoBind(this); this.options = options; this.rootNode = dom.createNode('ink-root'); this.rootNode.onComputeLayout = this.calculateLayout; this.isScreenReaderEnabled = options.isScreenReaderEnabled ?? process.env['INK_SCREEN_READER'] === 'true'; // CI detection takes precedence: even a TTY stdout in CI defaults to non-interactive. // Using Boolean(isTTY) (rather than an 'in' guard) correctly handles piped streams // where the property is absent (e.g. `node app.js | cat`). this.interactive = this.resolveInteractiveOption(options.interactive); this.alternateScreen = false; const unthrottled = options.debug || this.isScreenReaderEnabled; const maxFps = options.maxFps ?? 30; const renderThrottleMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0; if (unthrottled) { this.rootNode.onRender = this.onRender; this.throttledOnRender = undefined; } else { const throttled = throttle(this.onRender, renderThrottleMs, { leading: true, trailing: true, }); this.rootNode.onRender = () => { this.hasPendingThrottledRender = true; throttled(); }; this.throttledOnRender = throttled; } this.rootNode.onImmediateRender = this.onRender; this.log = logUpdate.create(options.stdout, { incremental: options.incrementalRendering, }); this.cursorPosition = undefined; this.throttledLog = unthrottled ? this.log : throttle( (output: string) => { const shouldWrite = this.log.willRender(output); const sync = this.shouldSync(); if (sync && shouldWrite) { this.options.stdout.write(bsu); } this.log(output); if (sync && shouldWrite) { this.options.stdout.write(esu); } }, undefined, { leading: true, trailing: true, }, ); // Ignore last render after unmounting a tree to prevent empty output before exit this.isUnmounted = false; this.isUnmounting = false; // Store concurrent mode setting this.isConcurrent = options.concurrent ?? false; // Store last output to only rerender when needed this.lastOutput = ''; this.lastOutputToRender = ''; this.lastOutputHeight = 0; this.lastTerminalWidth = getWindowSize(this.options.stdout).columns; // This variable is used only in debug mode to store full static output // so that it's rerendered every time, not just new static parts, like in non-debug mode this.fullStaticOutput = ''; // Use ConcurrentRoot for concurrent mode, LegacyRoot for legacy mode const rootTag = options.concurrent ? ConcurrentRoot : LegacyRoot; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.container = reconciler.createContainer( this.rootNode, rootTag, null, false, null, 'id', () => {}, () => {}, () => {}, () => {}, ); // Unmount when process exits this.unsubscribeExit = signalExit(this.unmount, {alwaysLast: false}); this.setAlternateScreen(Boolean(options.alternateScreen)); if (process.env['DEV'] === 'true') { // @ts-expect-error outdated types reconciler.injectIntoDevTools(); } if (options.patchConsole) { this.patchConsole(); } if (this.interactive) { options.stdout.on('resize', this.resized); this.unsubscribeResize = () => { options.stdout.off('resize', this.resized); }; } this.initKittyKeyboard(); this.exitPromise = new Promise((resolve, reject) => { this.resolveExitPromise = resolve; this.rejectExitPromise = reject; }); // Prevent global unhandled-rejection crashes when app code exits with an // error but consumers never call waitUntilExit(). void this.exitPromise.catch(noop); } resized = () => { const currentWidth = getWindowSize(this.options.stdout).columns; if (currentWidth < this.lastTerminalWidth) { // We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders. this.log.clear(); this.lastOutput = ''; this.lastOutputToRender = ''; } this.calculateLayout(); this.onRender(); this.lastTerminalWidth = currentWidth; }; resolveExitPromise: (result?: unknown) => void = () => {}; rejectExitPromise: (reason?: Error) => void = () => {}; unsubscribeExit: () => void = () => {}; handleAppExit = (errorOrResult?: unknown): void => { if (this.isUnmounted || this.isUnmounting) { return; } if (isErrorInput(errorOrResult)) { this.unmount(errorOrResult); return; } this.exitResult = errorOrResult; this.unmount(); }; setCursorPosition = (position: CursorPosition | undefined): void => { this.cursorPosition = position; this.log.setCursorPosition(position); }; restoreLastOutput = (): void => { if (!this.interactive) { return; } // Clear() resets log-update's cursor state, so replay the latest cursor intent // before restoring output after external stdout/stderr writes. this.log.setCursorPosition(this.cursorPosition); this.log(this.lastOutputToRender || this.lastOutput + '\n'); }; calculateLayout = () => { const terminalWidth = getWindowSize(this.options.stdout).columns; this.rootNode.yogaNode!.setWidth(terminalWidth); this.rootNode.yogaNode!.calculateLayout( undefined, undefined, Yoga.DIRECTION_LTR, ); }; onRender: () => void = () => { this.hasPendingThrottledRender = false; if (this.isUnmounted) { return; } if (this.nextRenderCommit) { this.nextRenderCommit.resolve(); this.nextRenderCommit = undefined; } const startTime = performance.now(); const {output, outputHeight, staticOutput} = render( this.rootNode, this.isScreenReaderEnabled, ); this.options.onRender?.({renderTime: performance.now() - startTime}); // If output isn't empty, it means new children have been added to it const hasStaticOutput = staticOutput && staticOutput !== '\n'; if (this.options.debug) { if (hasStaticOutput) { this.fullStaticOutput += staticOutput; } this.lastOutput = output; this.lastOutputToRender = output; this.lastOutputHeight = outputHeight; this.options.stdout.write(this.fullStaticOutput + output); return; } if (!this.interactive) { if (hasStaticOutput) { this.options.stdout.write(staticOutput); } this.lastOutput = output; this.lastOutputToRender = output + '\n'; this.lastOutputHeight = outputHeight; return; } if (this.isScreenReaderEnabled) { const sync = this.shouldSync(); if (sync) { this.options.stdout.write(bsu); } if (hasStaticOutput) { // We need to erase the main output before writing new static output const erase = this.lastOutputHeight > 0 ? ansiEscapes.eraseLines(this.lastOutputHeight) : ''; this.options.stdout.write(erase + staticOutput); // After erasing, the last output is gone, so we should reset its height this.lastOutputHeight = 0; } if (output === this.lastOutput && !hasStaticOutput) { if (sync) { this.options.stdout.write(esu); } return; } const terminalWidth = getWindowSize(this.options.stdout).columns; const wrappedOutput = wrapAnsi(output, terminalWidth, { trim: false, hard: true, }); // If we haven't erased yet, do it now. if (hasStaticOutput) { this.options.stdout.write(wrappedOutput); } else { const erase = this.lastOutputHeight > 0 ? ansiEscapes.eraseLines(this.lastOutputHeight) : ''; this.options.stdout.write(erase + wrappedOutput); } this.lastOutput = output; this.lastOutputToRender = wrappedOutput; this.lastOutputHeight = wrappedOutput === '' ? 0 : wrappedOutput.split('\n').length; if (sync) { this.options.stdout.write(esu); } return; } if (hasStaticOutput) { this.fullStaticOutput += staticOutput; } this.renderInteractiveFrame( output, outputHeight, hasStaticOutput ? staticOutput : '', ); }; render(node: ReactNode): void { const tree = ( {node} ); if (this.options.concurrent) { // Concurrent mode: use updateContainer (async scheduling) reconciler.updateContainer(tree, this.container, null, noop); } else { // Legacy mode: use updateContainerSync + flushSyncWork (sync) reconciler.updateContainerSync(tree, this.container, null, noop); reconciler.flushSyncWork(); } } writeToStdout(data: string): void { if (this.isUnmounted) { return; } if (this.options.debug) { this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput); return; } if (!this.interactive) { this.options.stdout.write(data); return; } const sync = this.shouldSync(); if (sync) { this.options.stdout.write(bsu); } this.log.clear(); this.options.stdout.write(data); this.restoreLastOutput(); if (sync) { this.options.stdout.write(esu); } } writeToStderr(data: string): void { if (this.isUnmounted) { return; } if (this.options.debug) { this.options.stderr.write(data); this.options.stdout.write(this.fullStaticOutput + this.lastOutput); return; } if (!this.interactive) { this.options.stderr.write(data); return; } const sync = this.shouldSync(); if (sync) { this.options.stdout.write(bsu); } this.log.clear(); this.options.stderr.write(data); this.restoreLastOutput(); if (sync) { this.options.stdout.write(esu); } } // eslint-disable-next-line @typescript-eslint/no-restricted-types unmount(error?: Error | number | null): void { if (this.isUnmounted || this.isUnmounting) { return; } this.isUnmounting = true; if (this.beforeExitHandler) { process.off('beforeExit', this.beforeExitHandler); this.beforeExitHandler = undefined; } const stdout = this.options.stdout as MaybeWritableStream; const {canWriteToStdout, hasWritableState} = getWritableStreamState(stdout); // Clear any pending throttled render timer on unmount. When stdout is writable, // flush so the final frame is emitted; otherwise cancel to avoid delayed callbacks. settleThrottle(this.throttledOnRender, canWriteToStdout); if (canWriteToStdout) { // If throttling is enabled and there is already a pending render, flushing above // is sufficient. Also avoid calling onRender() again when static output already // exists, as that can duplicate children output on exit (see issue #397). const shouldRenderFinalFrame = !this.throttledOnRender || (!this.hasPendingThrottledRender && this.fullStaticOutput === ''); if (shouldRenderFinalFrame) { this.calculateLayout(); this.onRender(); } } // Mark as unmounted after the final render but before stdout writes // that could re-enter exit() via synchronous write callbacks. this.isUnmounted = true; this.unsubscribeExit(); // Flush any pending throttled log writes if possible, otherwise cancel to // prevent delayed callbacks from writing to a closed stream. settleThrottle(this.throttledLog, canWriteToStdout); if (typeof this.restoreConsole === 'function') { // Once unmount starts, Ink stops trying to manage teardown-time // console output. Restoring the native console before React cleanup keeps // unmount behavior simple and avoids special-case handling for custom // streams, fullscreen frames, and alternate-screen teardown. this.restoreConsole(); } const finishUnmount = (): void => { if (typeof this.unsubscribeResize === 'function') { this.unsubscribeResize(); } // Cancel any in-progress auto-detection before checking protocol state if (this.cancelKittyDetection) { this.cancelKittyDetection(); } if (canWriteToStdout) { if (this.kittyProtocolEnabled) { this.writeBestEffort(this.options.stdout, '\u001B[ { if (isErrorInput(error)) { this.rejectExitPromise(error); } else { this.resolveExitPromise(exitResult); } }; const isProcessExiting = error !== undefined && !isErrorInput(error); if (isProcessExiting) { resolveOrReject(); } else if (canWriteToStdout && hasWritableState) { this.options.stdout.write('', resolveOrReject); } else { setImmediate(resolveOrReject); } }; const concurrentReconciler = reconciler as { flushPassiveEffects?: () => boolean; }; if (this.options.concurrent) { reconciler.updateContainerSync(null, this.container, null, noop); reconciler.flushSyncWork(); concurrentReconciler.flushPassiveEffects?.(); finishUnmount(); } else { // Legacy mode: use updateContainerSync + flushSyncWork (sync) reconciler.updateContainerSync(null, this.container, null, noop); reconciler.flushSyncWork(); finishUnmount(); } } async waitUntilExit(): Promise { if (!this.beforeExitHandler) { this.beforeExitHandler = () => { this.unmount(); }; process.once('beforeExit', this.beforeExitHandler); } return this.exitPromise; } async waitUntilRenderFlush(): Promise { if (this.isUnmounted || this.isUnmounting) { await this.awaitExit(); return; } // Yield to the macrotask queue so that React's scheduler has a chance to // fire passive effects and process any work they enqueued. await yieldImmediate(); if (this.isUnmounted || this.isUnmounting) { await this.awaitExit(); return; } // In concurrent mode, React's scheduler may still be mid-render after // the yield. Wait for the next render commit instead of polling. if (this.isConcurrent && this.hasPendingConcurrentWork()) { await Promise.race([this.awaitNextRender(), this.awaitExit()]); if (this.isUnmounted || this.isUnmounting) { this.nextRenderCommit = undefined; await this.awaitExit(); return; } } reconciler.flushSyncWork(); const stdout = this.options.stdout as MaybeWritableStream; const {canWriteToStdout, hasWritableState} = getWritableStreamState(stdout); // Flush pending throttled render/log timers so their output is included in this wait. settleThrottle(this.throttledOnRender, canWriteToStdout); settleThrottle(this.throttledLog, canWriteToStdout); if (canWriteToStdout && hasWritableState) { await new Promise(resolve => { this.options.stdout.write('', () => { resolve(); }); }); return; } await yieldImmediate(); } clear(): void { if (this.interactive && !this.options.debug) { this.log.clear(); // Sync lastOutput so that unmount's final onRender // sees it as unchanged and log-update skips it this.log.sync(this.lastOutputToRender || this.lastOutput + '\n'); } } patchConsole(): void { if (this.options.debug) { return; } this.restoreConsole = patchConsole((stream, data) => { if (stream === 'stdout') { this.writeToStdout(data); } if (stream === 'stderr') { const isReactMessage = data.startsWith('The above error occurred'); if (!isReactMessage) { this.writeToStderr(data); } } }); } private setAlternateScreen(enabled: boolean): void { this.alternateScreen = this.resolveAlternateScreenOption( enabled, this.interactive, ); if (this.alternateScreen) { this.writeBestEffort( this.options.stdout, ansiEscapes.enterAlternativeScreen, ); this.writeBestEffort(this.options.stdout, hideCursorEscape); } } private resolveInteractiveOption(interactive: boolean | undefined): boolean { return interactive ?? (!isInCi && Boolean(this.options.stdout.isTTY)); } private resolveAlternateScreenOption( alternateScreen: boolean | undefined, interactive: boolean, ): boolean { return ( Boolean(alternateScreen) && interactive && Boolean(this.options.stdout.isTTY) ); } private shouldSync(): boolean { return shouldSynchronize(this.options.stdout, this.interactive); } // Best-effort write: streams may already be destroyed during shutdown. private writeBestEffort(stream: NodeJS.WriteStream, data: string): void { try { stream.write(data); } catch {} } // Waits for the exit promise to settle, suppressing any rejection. // Errors are surfaced via waitUntilExit() instead. private async awaitExit(): Promise { try { await this.exitPromise; } catch {} } private hasPendingConcurrentWork(): boolean { const concurrentContainer = this.container as { pendingLanes?: number; callbackNode?: unknown; }; return ( (concurrentContainer.pendingLanes ?? 0) !== 0 && concurrentContainer.callbackNode !== undefined && concurrentContainer.callbackNode !== null ); } private async awaitNextRender(): Promise { if (!this.nextRenderCommit) { let resolveRender!: () => void; const promise = new Promise(resolve => { resolveRender = resolve; }); this.nextRenderCommit = {promise, resolve: resolveRender}; } return this.nextRenderCommit.promise; } private renderInteractiveFrame( output: string, outputHeight: number, staticOutput: string, ): void { const hasStaticOutput = staticOutput !== ''; const isTty = this.options.stdout.isTTY; // Detect fullscreen: output fills or exceeds terminal height. // Only apply when writing to a real TTY — piped output always gets trailing newlines. const viewportRows = isTty ? getWindowSize(this.options.stdout).rows : 24; const isFullscreen = isTty && outputHeight >= viewportRows; const outputToRender = isFullscreen ? output : output + '\n'; const shouldClearTerminal = shouldClearTerminalForFrame({ isTty, viewportRows, previousOutputHeight: this.lastOutputHeight, nextOutputHeight: outputHeight, isUnmounting: this.isUnmounting, }); if (shouldClearTerminal) { const sync = this.shouldSync(); if (sync) { this.options.stdout.write(bsu); } this.options.stdout.write( ansiEscapes.clearTerminal + this.fullStaticOutput + output, ); this.lastOutput = output; this.lastOutputToRender = outputToRender; this.lastOutputHeight = outputHeight; this.log.sync(outputToRender); if (sync) { this.options.stdout.write(esu); } return; } // To ensure static output is cleanly rendered before main output, clear main output first if (hasStaticOutput) { const sync = this.shouldSync(); if (sync) { this.options.stdout.write(bsu); } this.log.clear(); this.options.stdout.write(staticOutput); this.log(outputToRender); if (sync) { this.options.stdout.write(esu); } } else if (output !== this.lastOutput || this.log.isCursorDirty()) { // ThrottledLog manages its own bsu/esu at actual write time this.throttledLog(outputToRender); } this.lastOutput = output; this.lastOutputToRender = outputToRender; this.lastOutputHeight = outputHeight; } private initKittyKeyboard(): void { // Protocol is opt-in: if kittyKeyboard is not specified, do nothing if (!this.options.kittyKeyboard) { return; } const opts = this.options.kittyKeyboard; const mode = opts.mode ?? 'auto'; if (mode === 'disabled') { return; } const flags: KittyFlagName[] = opts.flags ?? ['disambiguateEscapeCodes']; // 'enabled' force-enables the protocol as long as both streams are TTYs, // regardless of the interactive setting (e.g. even in CI). if (mode === 'enabled') { if (this.options.stdin.isTTY && this.options.stdout.isTTY) { this.enableKittyProtocol(flags); } return; } // Auto mode: require interactive + TTY if ( !this.interactive || !this.options.stdin.isTTY || !this.options.stdout.isTTY ) { return; } // Auto mode: query the terminal for kitty keyboard protocol support. // The CSI ? u query is safe to send to any terminal — unsupporting // terminals simply won't respond, and the 200ms timeout handles that. // This avoids maintaining a hardcoded whitelist of terminal names. this.confirmKittySupport(flags); } private confirmKittySupport(flags: KittyFlagName[]): void { const {stdin, stdout} = this.options; let responseBuffer: number[] = []; const cleanup = (): void => { this.cancelKittyDetection = undefined; clearTimeout(timer); stdin.removeListener('data', onData); // Re-emit any buffered data that wasn't the protocol response, // so it isn't lost from Ink's normal input pipeline. // Clear responseBuffer afterwards to make cleanup idempotent. const remaining = stripKittyQueryResponsesAndTrailingPartial(responseBuffer); responseBuffer = []; if (remaining.length > 0) { stdin.unshift(Buffer.from(remaining)); } }; const onData = (data: Uint8Array | string): void => { const chunk = typeof data === 'string' ? Buffer.from(data) : data; for (const byte of chunk) { responseBuffer.push(byte); } if (hasCompleteKittyQueryResponse(responseBuffer)) { cleanup(); if (!this.isUnmounted) { this.enableKittyProtocol(flags); } } }; // Attach listener before writing the query so that synchronous // or immediate responses are not missed. stdin.on('data', onData); const timer = setTimeout(cleanup, 200); this.cancelKittyDetection = cleanup; stdout.write('\u001B[?u'); } private enableKittyProtocol(flags: KittyFlagName[]): void { this.options.stdout.write(`\u001B[>${resolveFlags(flags)}u`); this.kittyProtocolEnabled = true; } } ================================================ FILE: src/input-parser.ts ================================================ const escape = '\u001B'; const pasteStart = '\u001B[200~'; const pasteEnd = '\u001B[201~'; export type InputEvent = string | {readonly paste: string}; type ParsedInput = { readonly events: InputEvent[]; readonly pending: string; }; type ParsedSequence = | { readonly sequence: string; readonly nextIndex: number; } | 'pending' | undefined; const isCsiParameterByte = (byte: number): boolean => { return byte >= 0x30 && byte <= 0x3f; }; const isCsiIntermediateByte = (byte: number): boolean => { return byte >= 0x20 && byte <= 0x2f; }; const isCsiFinalByte = (byte: number): boolean => { return byte >= 0x40 && byte <= 0x7e; }; const parseCsiSequence = ( input: string, startIndex: number, prefixLength: number, ): ParsedSequence => { const csiPayloadStart = startIndex + prefixLength + 1; let index = csiPayloadStart; for (; index < input.length; index++) { const byte = input.codePointAt(index); if (byte === undefined) { return 'pending'; } if (isCsiParameterByte(byte) || isCsiIntermediateByte(byte)) { continue; } // Preserve legacy terminal function-key sequences like ESC[[A and ESC[[5~. if (byte === 0x5b && index === csiPayloadStart) { continue; } if (isCsiFinalByte(byte)) { return { sequence: input.slice(startIndex, index + 1), nextIndex: index + 1, }; } return undefined; } return 'pending'; }; const parseSs3Sequence = ( input: string, startIndex: number, prefixLength: number, ): ParsedSequence => { const nextIndex = startIndex + prefixLength + 2; if (nextIndex > input.length) { return 'pending'; } const finalByte = input.codePointAt(nextIndex - 1); if (finalByte === undefined || !isCsiFinalByte(finalByte)) { return undefined; } return { sequence: input.slice(startIndex, nextIndex), nextIndex, }; }; const parseControlSequence = ( input: string, startIndex: number, prefixLength: number, ): ParsedSequence => { const sequenceType = input[startIndex + prefixLength]; if (sequenceType === undefined) { return 'pending'; } if (sequenceType === '[') { return parseCsiSequence(input, startIndex, prefixLength); } if (sequenceType === 'O') { return parseSs3Sequence(input, startIndex, prefixLength); } return undefined; }; const parseEscapedCodePoint = ( input: string, escapeIndex: number, ): { readonly sequence: string; readonly nextIndex: number; } => { const nextCodePoint = input.codePointAt(escapeIndex + 1); const nextCodePointLength = nextCodePoint !== undefined && nextCodePoint > 0xff_ff ? 2 : 1; const nextIndex = escapeIndex + 1 + nextCodePointLength; return { sequence: input.slice(escapeIndex, nextIndex), nextIndex, }; }; type ParsedEscapeSequence = | { readonly sequence: string; readonly nextIndex: number; } | 'pending'; const parseEscapeSequence = ( input: string, escapeIndex: number, ): ParsedEscapeSequence => { if (escapeIndex === input.length - 1) { return 'pending'; } const next = input[escapeIndex + 1]!; if (next === escape) { if (escapeIndex + 2 >= input.length) { return 'pending'; } const doubleEscapeSequence = parseControlSequence(input, escapeIndex, 2); if (doubleEscapeSequence === 'pending') { return 'pending'; } if (doubleEscapeSequence) { return doubleEscapeSequence; } return { sequence: input.slice(escapeIndex, escapeIndex + 2), nextIndex: escapeIndex + 2, }; } const controlSequence = parseControlSequence(input, escapeIndex, 1); if (controlSequence === 'pending') { return 'pending'; } if (controlSequence) { return controlSequence; } return parseEscapedCodePoint(input, escapeIndex); }; /** Split a chunk of non-escape text so that delete (0x7F) and backspace (0x08) characters become individual events. When a user holds the delete or backspace key, the terminal sends repeated bytes in a single stdin chunk. Without splitting, `parseKeypress` receives the multi-byte string and fails to recognize it as a key event, corrupting the input state. Other control characters like `\r` and `\t` are NOT split because they can legitimately appear inside pasted text. */ const splitDeleteAndBackspace = (text: string, events: InputEvent[]): void => { let textSegmentStart = 0; for (let index = 0; index < text.length; index++) { const character = text[index]!; if (character === '\u007F' || character === '\u0008') { if (index > textSegmentStart) { events.push(text.slice(textSegmentStart, index)); } events.push(character); textSegmentStart = index + 1; } } if (textSegmentStart < text.length) { events.push(text.slice(textSegmentStart)); } }; const parseKeypresses = (input: string): ParsedInput => { const events: InputEvent[] = []; let index = 0; const pendingFrom = (pendingStartIndex: number): ParsedInput => ({ events, pending: input.slice(pendingStartIndex), }); while (index < input.length) { const escapeIndex = input.indexOf(escape, index); if (escapeIndex === -1) { splitDeleteAndBackspace(input.slice(index), events); return { events, pending: '', }; } if (escapeIndex > index) { splitDeleteAndBackspace(input.slice(index, escapeIndex), events); } const parsedEscapeSequence = parseEscapeSequence(input, escapeIndex); if (parsedEscapeSequence === 'pending') { return pendingFrom(escapeIndex); } if (parsedEscapeSequence.sequence === pasteStart) { const afterStart = parsedEscapeSequence.nextIndex; const endIndex = input.indexOf(pasteEnd, afterStart); if (endIndex === -1) { return pendingFrom(escapeIndex); } events.push({paste: input.slice(afterStart, endIndex)}); index = endIndex + pasteEnd.length; continue; } events.push(parsedEscapeSequence.sequence); index = parsedEscapeSequence.nextIndex; } return { events, pending: '', }; }; export type InputParser = { push: (chunk: string) => InputEvent[]; hasPendingEscape: () => boolean; flushPendingEscape: () => string | undefined; reset: () => void; }; export const createInputParser = (): InputParser => { let pending = ''; return { push(chunk) { const parsedInput = parseKeypresses(pending + chunk); pending = parsedInput.pending; return parsedInput.events; }, hasPendingEscape() { // Don't trigger the escape flush timer while assembling a paste start // marker (`\u001B[200` and then `~`) or while waiting for paste end. return ( pending.startsWith(escape) && !pending.startsWith(pasteStart) && pending !== '\u001B[200' ); }, flushPendingEscape() { if (!pending.startsWith(escape)) { return undefined; } const pendingEscape = pending; pending = ''; return pendingEscape; }, reset() { pending = ''; }, }; }; ================================================ FILE: src/instances.ts ================================================ // Store all instances of Ink (instance.js) to ensure that consecutive render() calls // use the same instance of Ink and don't create a new one // // This map has to be stored in a separate file, because render.js creates instances, // but instance.js should delete itself from the map on unmount import type Ink from './ink.js'; const instances = new WeakMap(); export default instances; ================================================ FILE: src/kitty-keyboard.ts ================================================ // 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, } as const; // Valid flag names for the kitty keyboard protocol. export type KittyFlagName = keyof typeof kittyFlags; // Converts an array of flag names to the corresponding bitmask value. export function resolveFlags(flags: KittyFlagName[]): number { 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, } as const; // Options for configuring kitty keyboard protocol. export type KittyKeyboardOptions = { // Mode for kitty keyboard protocol support. // - 'auto': Attempt to detect terminal support (default) // - 'enabled': Force enable the protocol // - 'disabled': Never enable the protocol mode?: 'auto' | 'enabled' | 'disabled'; // Protocol flags to request from the terminal. // Pass an array of flag name strings. // // Available flags: // - 'disambiguateEscapeCodes' - Disambiguate escape codes (default) // - '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 flags?: KittyFlagName[]; }; ================================================ FILE: src/log-update.ts ================================================ import {type Writable} from 'node:stream'; import ansiEscapes from 'ansi-escapes'; import cliCursor from 'cli-cursor'; import { type CursorPosition, cursorPositionChanged, buildCursorSuffix, buildCursorOnlySequence, buildReturnToBottomPrefix, hideCursorEscape, } from './cursor-helpers.js'; export type {CursorPosition} from './cursor-helpers.js'; export type LogUpdate = { clear: () => void; done: () => void; reset: () => void; sync: (str: string) => void; setCursorPosition: (position: CursorPosition | undefined) => void; isCursorDirty: () => boolean; willRender: (str: string) => boolean; (str: string): boolean; }; // Count visible lines in a string, ignoring the trailing empty element // that `split('\n')` produces when the string ends with '\n'. const visibleLineCount = (lines: string[], str: string): number => str.endsWith('\n') ? lines.length - 1 : lines.length; const createStandard = ( stream: Writable, {showCursor = false} = {}, ): LogUpdate => { let previousLineCount = 0; let previousOutput = ''; let hasHiddenCursor = false; let cursorPosition: CursorPosition | undefined; let cursorDirty = false; let previousCursorPosition: CursorPosition | undefined; let cursorWasShown = false; const getActiveCursor = () => (cursorDirty ? cursorPosition : undefined); const hasChanges = ( str: string, activeCursor: CursorPosition | undefined, ): boolean => { const cursorChanged = cursorPositionChanged( activeCursor, previousCursorPosition, ); return str !== previousOutput || cursorChanged; }; const render = (str: string) => { if (!showCursor && !hasHiddenCursor) { cliCursor.hide(stream); hasHiddenCursor = true; } // 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 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 = () => { const prefix = buildReturnToBottomPrefix( cursorWasShown, previousLineCount, previousCursorPosition, ); stream.write(prefix + ansiEscapes.eraseLines(previousLineCount)); previousOutput = ''; previousLineCount = 0; previousCursorPosition = undefined; cursorWasShown = false; }; render.done = () => { previousOutput = ''; previousLineCount = 0; previousCursorPosition = undefined; cursorWasShown = false; if (!showCursor) { cliCursor.show(stream); hasHiddenCursor = false; } }; render.reset = () => { previousOutput = ''; previousLineCount = 0; previousCursorPosition = undefined; cursorWasShown = false; }; render.sync = (str: string) => { 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 | undefined) => { cursorPosition = position; cursorDirty = true; }; render.isCursorDirty = () => cursorDirty; render.willRender = (str: string) => hasChanges(str, getActiveCursor()); return render; }; const createIncremental = ( stream: Writable, {showCursor = false} = {}, ): LogUpdate => { let previousLines: string[] = []; let previousOutput = ''; let hasHiddenCursor = false; let cursorPosition: CursorPosition | undefined; let cursorDirty = false; let previousCursorPosition: CursorPosition | undefined; let cursorWasShown = false; const getActiveCursor = () => (cursorDirty ? cursorPosition : undefined); const hasChanges = ( str: string, activeCursor: CursorPosition | undefined, ): boolean => { const cursorChanged = cursorPositionChanged( activeCursor, previousCursorPosition, ); return str !== previousOutput || cursorChanged; }; const render = (str: string) => { if (!showCursor && !hasHiddenCursor) { cliCursor.hide(stream); hasHiddenCursor = true; } // 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 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 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: string[] = []; buffer.push(returnPrefix); // Clear extra lines if the current content's line count is lower than the previous. 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(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]) { // 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; } buffer.push( ansiEscapes.cursorTo(0) + nextLines[i] + ansiEscapes.eraseEndLine + // 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('')); cursorWasShown = activeCursor !== undefined; previousCursorPosition = activeCursor ? {...activeCursor} : undefined; previousOutput = str; previousLines = nextLines; return true; }; render.clear = () => { const prefix = buildReturnToBottomPrefix( cursorWasShown, previousLines.length, previousCursorPosition, ); stream.write(prefix + ansiEscapes.eraseLines(previousLines.length)); previousOutput = ''; previousLines = []; previousCursorPosition = undefined; cursorWasShown = false; }; render.done = () => { previousOutput = ''; previousLines = []; previousCursorPosition = undefined; cursorWasShown = false; if (!showCursor) { cliCursor.show(stream); hasHiddenCursor = false; } }; render.reset = () => { previousOutput = ''; previousLines = []; previousCursorPosition = undefined; cursorWasShown = false; }; render.sync = (str: string) => { 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 | undefined) => { cursorPosition = position; cursorDirty = true; }; render.isCursorDirty = () => cursorDirty; render.willRender = (str: string) => hasChanges(str, getActiveCursor()); return render; }; const create = ( stream: Writable, {showCursor = false, incremental = false} = {}, ): LogUpdate => { if (incremental) { return createIncremental(stream, {showCursor}); } return createStandard(stream, {showCursor}); }; const logUpdate = {create}; export default logUpdate; ================================================ FILE: src/measure-element.ts ================================================ import {type DOMElement} from './dom.js'; type Output = { /** Element width. */ width: number; /** Element height. */ height: number; }; /** Measure the dimensions of a particular `` element. Returns an object with `width` and `height` properties. This function is useful when your component needs to know the amount of available space it has. You can use it when you need to change the layout based on the length of its content. Note: `measureElement()` returns `{width: 0, height: 0}` when called during render (before layout is calculated). Call it from post-render code, such as `useEffect`, `useLayoutEffect`, input handlers, or timer callbacks. When content changes, pass the relevant dependency to your effect so it re-measures after each update. */ const measureElement = (node: DOMElement): Output => ({ width: node.yogaNode?.getComputedWidth() ?? 0, height: node.yogaNode?.getComputedHeight() ?? 0, }); export default measureElement; ================================================ FILE: src/measure-text.ts ================================================ import widestLine from 'widest-line'; const cache = new Map(); type Output = { width: number; height: number; }; const measureText = (text: string): Output => { if (text.length === 0) { return { width: 0, height: 0, }; } const cachedDimensions = cache.get(text); if (cachedDimensions) { return cachedDimensions; } const width = widestLine(text); const height = text.split('\n').length; const dimensions = {width, height}; cache.set(text, dimensions); return dimensions; }; export default measureText; ================================================ FILE: src/output.ts ================================================ import sliceAnsi from 'slice-ansi'; import stringWidth from 'string-width'; import { type StyledChar, styledCharsFromTokens, styledCharsToString, tokenize, } from '@alcalzone/ansi-tokenize'; import {type OutputTransformer} from './render-node-to-output.js'; /** "Virtual" output class Handles the positioning and saving of the output of each node in the tree. Also responsible for applying transformations to each character of the output. Used to generate the final output of all nodes before writing it to actual output stream (e.g. stdout) */ type Options = { width: number; height: number; }; type Operation = WriteOperation | ClipOperation | UnclipOperation; type WriteOperation = { type: 'write'; x: number; y: number; text: string; transformers: OutputTransformer[]; }; type ClipOperation = { type: 'clip'; clip: Clip; }; type Clip = { x1: number | undefined; x2: number | undefined; y1: number | undefined; y2: number | undefined; }; type UnclipOperation = { type: 'unclip'; }; class OutputCaches { widths = new Map(); blockWidths = new Map(); styledChars = new Map(); getStyledChars(line: string): StyledChar[] { let cached = this.styledChars.get(line); if (cached === undefined) { cached = styledCharsFromTokens(tokenize(line)); this.styledChars.set(line, cached); } return cached; } getStringWidth(text: string): number { let cached = this.widths.get(text); if (cached === undefined) { cached = stringWidth(text); this.widths.set(text, cached); } return cached; } getWidestLine(text: string): number { let cached = this.blockWidths.get(text); if (cached === undefined) { let lineWidth = 0; for (const line of text.split('\n')) { lineWidth = Math.max(lineWidth, this.getStringWidth(line)); } cached = lineWidth; this.blockWidths.set(text, cached); } return cached; } } export default class Output { width: number; height: number; private readonly operations: Operation[] = []; private readonly caches: OutputCaches = new OutputCaches(); constructor(options: Options) { const {width, height} = options; this.width = width; this.height = height; } write( x: number, y: number, text: string, options: {transformers: OutputTransformer[]}, ): void { const {transformers} = options; if (!text) { return; } this.operations.push({ type: 'write', x, y, text, transformers, }); } clip(clip: Clip) { this.operations.push({ type: 'clip', clip, }); } unclip() { this.operations.push({ type: 'unclip', }); } get(): {output: string; height: number} { // Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved const output: StyledChar[][] = []; for (let y = 0; y < this.height; y++) { const row: StyledChar[] = []; for (let x = 0; x < this.width; x++) { row.push({ type: 'char', value: ' ', fullWidth: false, styles: [], }); } output.push(row); } const clips: Clip[] = []; for (const operation of this.operations) { if (operation.type === 'clip') { clips.push(operation.clip); } if (operation.type === 'unclip') { clips.pop(); } if (operation.type === 'write') { const {text, transformers} = operation; let {x, y} = operation; let lines = text.split('\n'); const clip = clips.at(-1); if (clip) { const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'; const clipVertically = typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number'; // If text is positioned outside of clipping area altogether, // skip to the next operation to avoid unnecessary calculations if (clipHorizontally) { const width = this.caches.getWidestLine(text); if (x + width < clip.x1! || x > clip.x2!) { continue; } } if (clipVertically) { const height = lines.length; if (y + height < clip.y1! || y > clip.y2!) { continue; } } if (clipHorizontally) { lines = lines.map(line => { const from = x < clip.x1! ? clip.x1! - x : 0; const width = this.caches.getStringWidth(line); const to = x + width > clip.x2! ? clip.x2! - x : width; return sliceAnsi(line, from, to); }); if (x < clip.x1!) { x = clip.x1!; } } if (clipVertically) { const from = y < clip.y1! ? clip.y1! - y : 0; const height = lines.length; const to = y + height > clip.y2! ? clip.y2! - y : height; lines = lines.slice(from, to); if (y < clip.y1!) { y = clip.y1!; } } } let offsetY = 0; for (let [index, line] of lines.entries()) { const currentLine = output[y + offsetY]; // Line can be missing if `text` is taller than height of pre-initialized `this.output` if (!currentLine) { continue; } for (const transformer of transformers) { line = transformer(line, index); } const characters = this.caches.getStyledChars(line); let offsetX = x; for (const character of characters) { currentLine[offsetX] = character; // Determine printed width using string-width to align with measurement const characterWidth = Math.max( 1, this.caches.getStringWidth(character.value), ); // For multi-column characters, clear following cells to avoid stray spaces/artifacts if (characterWidth > 1) { for (let index = 1; index < characterWidth; index++) { currentLine[offsetX + index] = { type: 'char', value: '', fullWidth: false, styles: character.styles, }; } } offsetX += characterWidth; } offsetY++; } } } const generatedOutput = output .map(line => { // See https://github.com/vadimdemedes/ink/pull/564#issuecomment-1637022742 const lineWithoutEmptyItems = line.filter(item => item !== undefined); return styledCharsToString(lineWithoutEmptyItems).trimEnd(); }) .join('\n'); return { output: generatedOutput, height: output.length, }; } } ================================================ FILE: src/parse-keypress.ts ================================================ // 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])$/; const fnKeyRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/; const keyName: Record = { /* xterm/gnome ESC O letter */ OP: 'f1', OQ: 'f2', OR: 'f3', OS: 'f4', /* xterm/rxvt ESC [ number ~ */ '[11~': 'f1', '[12~': 'f2', '[13~': 'f3', '[14~': 'f4', /* from Cygwin and used in libuv */ '[[A': 'f1', '[[B': 'f2', '[[C': 'f3', '[[D': 'f4', '[[E': 'f5', /* common */ '[15~': 'f5', '[17~': 'f6', '[18~': 'f7', '[19~': 'f8', '[20~': 'f9', '[21~': 'f10', '[23~': 'f11', '[24~': 'f12', /* xterm ESC [ letter */ '[A': 'up', '[B': 'down', '[C': 'right', '[D': 'left', '[E': 'clear', '[F': 'end', '[H': 'home', /* xterm/gnome ESC O letter */ OA: 'up', OB: 'down', OC: 'right', OD: 'left', OE: 'clear', OF: 'end', OH: 'home', /* xterm/rxvt ESC [ number ~ */ '[1~': 'home', '[2~': 'insert', '[3~': 'delete', '[4~': 'end', '[5~': 'pageup', '[6~': 'pagedown', /* putty */ '[[5~': 'pageup', '[[6~': 'pagedown', /* rxvt */ '[7~': 'home', '[8~': 'end', /* rxvt keys with modifiers */ '[a': 'up', '[b': 'down', '[c': 'right', '[d': 'left', '[e': 'clear', '[2$': 'insert', '[3$': 'delete', '[5$': 'pageup', '[6$': 'pagedown', '[7$': 'home', '[8$': 'end', Oa: 'up', Ob: 'down', Oc: 'right', Od: 'left', Oe: 'clear', '[2^': 'insert', '[3^': 'delete', '[5^': 'pageup', '[6^': 'pagedown', '[7^': 'home', '[8^': 'end', /* misc. */ '[Z': 'tab', }; export const nonAlphanumericKeys = [...Object.values(keyName), 'backspace']; const isShiftKey = (code: string) => { return [ '[a', '[b', '[c', '[d', '[e', '[2$', '[3$', '[5$', '[6$', '[7$', '[8$', '[Z', ].includes(code); }; const isCtrlKey = (code: string) => { return [ 'Oa', 'Ob', 'Oc', 'Od', 'Oe', '[2^', '[3^', '[5^', '[6^', '[7^', '[8^', ].includes(code); }; type ParsedKey = { name: string; ctrl: boolean; meta: boolean; shift: boolean; option: boolean; sequence: string; raw: string | undefined; code?: string; super?: boolean; hyper?: boolean; capsLock?: boolean; numLock?: boolean; eventType?: 'press' | 'repeat' | 'release'; isKittyProtocol?: boolean; text?: string; // Whether this key represents printable text input. // When false, the key is a control/function/modifier key that should not // produce text input (e.g., arrows, function keys, capslock, media keys). // Only set by the kitty protocol parser. isPrintable?: boolean; }; // 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: Record = { 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: Record = { 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: Record = { 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: number): boolean => cp >= 0 && cp <= 0x10_ffff && !(cp >= 0xd8_00 && cp <= 0xdf_ff); const safeFromCodePoint = (cp: number): string => isValidCodepoint(cp) ? String.fromCodePoint(cp) : '?'; type EventType = 'press' | 'repeat' | 'release'; function resolveEventType(value: number): EventType { if (value === 3) return 'release'; if (value === 2) return 'repeat'; return 'press'; } function parseKittyModifiers( modifiers: number, ): Pick< ParsedKey, | 'ctrl' | 'shift' | 'meta' | 'option' | 'super' | 'hyper' | 'capsLock' | 'numLock' > { 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: string): ParsedKey | null => { 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: string | undefined; if (textField) { text = textField .split(':') .map(cp => safeFromCodePoint(parseInt(cp, 10))) .join(''); } // Determine key name from codepoint let name: string; let isPrintable: boolean; 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: string): ParsedKey | null => { 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: Buffer | string = ''): ParsedKey => { let parts; if (Buffer.isBuffer(s)) { if (s[0]! > 127 && s[1] === undefined) { (s[0] as unknown as number) -= 128; s = '\x1b' + String(s); } else { s = String(s); } } else if (s !== undefined && typeof s !== 'string') { s = String(s); } else if (!s) { s = ''; } // 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: ParsedKey = { name: '', ctrl: false, meta: false, shift: false, option: false, sequence: s, raw: s, }; key.sequence = key.sequence || s || key.name; if (s === '\r' || s === '\x1b\r') { // carriage return (or option+return on macOS) key.raw = undefined; key.name = 'return'; key.option = s.length === 2; } else if (s === '\n') { // enter, should have been called linefeed key.name = 'enter'; } else if (s === '\t') { // tab key.name = 'tab'; } else if (s === '\b' || s === '\x1b\b') { // backspace or ctrl+h key.name = 'backspace'; key.meta = s.charAt(0) === '\x1b'; } else if (s === '\x7f' || s === '\x1b\x7f') { // TODO(vadimdemedes): `enquirer` detects delete key as backspace, but I had to split them up to avoid breaking changes in Ink. Merge them back together in the next major version. // delete key.name = 'delete'; key.meta = s.charAt(0) === '\x1b'; } else if (s === '\x1b' || s === '\x1b\x1b') { // escape key key.name = 'escape'; key.meta = s.length === 2; } else if (s === ' ' || s === '\x1b ') { key.name = 'space'; key.meta = s.length === 2; } else if (s.length === 1 && s <= '\x1a') { // ctrl+letter key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1); key.ctrl = true; } else if (s.length === 1 && s >= '0' && s <= '9') { // number key.name = 'number'; } else if (s.length === 1 && s >= 'a' && s <= 'z') { // lowercase letter key.name = s; } else if (s.length === 1 && s >= 'A' && s <= 'Z') { // shift+letter key.name = s.toLowerCase(); key.shift = true; } else if ((parts = metaKeyCodeRe.exec(s))) { // meta+character key key.meta = true; key.shift = /^[A-Z]$/.test(parts[1]!); } else if ((parts = fnKeyRe.exec(s))) { const segs = [...s]; if (segs[0] === '\u001b' && segs[1] === '\u001b') { key.option = true; } // ansi escape sequence // reassemble the key code leaving out leading \x1b's, // the modifier key bitflag and any meaningless "1;" sequence const code = [parts[1], parts[2], parts[4], parts[6]] .filter(Boolean) .join(''); const modifier = ((parts[3] || parts[5] || 1) as number) - 1; // Parse the key modifier key.ctrl = !!(modifier & 4); key.meta = !!(modifier & 10); key.shift = !!(modifier & 1); key.code = code; key.name = keyName[code]!; key.shift = isShiftKey(code) || key.shift; key.ctrl = isCtrlKey(code) || key.ctrl; } return key; }; export default parseKeypress; ================================================ FILE: src/reconciler.ts ================================================ import process from 'node:process'; import createReconciler, {type ReactContext} from 'react-reconciler'; import { DefaultEventPriority, NoEventPriority, } from 'react-reconciler/constants.js'; import * as Scheduler from 'scheduler'; import Yoga, {type Node as YogaNode} from 'yoga-layout'; import {createContext, version as reactVersion} from 'react'; import { createTextNode, appendChildNode, insertBeforeNode, removeChildNode, emitLayoutListeners, setStyle, setTextNodeValue, createNode, setAttribute, type DOMNodeAttribute, type TextNode, type ElementNames, type DOMElement, } from './dom.js'; import applyStyles, {type Styles} from './styles.js'; import {type OutputTransformer} from './render-node-to-output.js'; // We need to conditionally perform devtools connection to avoid // accidentally breaking other third-party code. // See https://github.com/vadimdemedes/ink/issues/384 // See https://github.com/vadimdemedes/ink/issues/648 if (process.env['DEV'] === 'true') { // Intentionally no warning when the package is missing. // DEV may be set for other reasons; devtools is opt-in via installing the package. let isDevtoolsInstalled = false; try { import.meta.resolve('react-devtools-core'); isDevtoolsInstalled = true; } catch {} if (isDevtoolsInstalled) { await import('./devtools.js'); } } type AnyObject = Record; const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => { if (before === after) { return; } if (!before) { return after; } const changed: AnyObject = {}; let isChanged = false; for (const key of Object.keys(before)) { const isDeleted = after ? !Object.hasOwn(after, key) : true; if (isDeleted) { changed[key] = undefined; isChanged = true; } } if (after) { for (const key of Object.keys(after)) { if (after[key] !== before[key]) { changed[key] = after[key]; isChanged = true; } } } return isChanged ? changed : undefined; }; const cleanupYogaNode = (node?: YogaNode): void => { node?.unsetMeasureFunc(); node?.freeRecursive(); }; type Props = Record; type HostContext = { isInsideText: boolean; }; let currentUpdatePriority = NoEventPriority; let currentRootNode: DOMElement | undefined; async function loadPackageJson() { const fs = await import('node:fs'); const content = fs.readFileSync( new URL('../package.json', import.meta.url), 'utf8', ); const parsedContent = JSON.parse(content) as | { name?: string; version?: string; } | undefined; return { name: parsedContent?.name, version: parsedContent?.version, }; } let packageInfo = { name: 'ink', version: reactVersion, }; if (process.env['DEV'] === 'true') { try { const loaded = await loadPackageJson(); packageInfo = { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing name: loaded.name || packageInfo.name, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing version: loaded.version || packageInfo.version, }; } catch (error) { console.warn( 'Failed to load package.json in development mode. Falling back to default renderer metadata.', error, ); } } export default createReconciler< ElementNames, Props, DOMElement, DOMElement, TextNode, DOMElement, unknown, unknown, unknown, HostContext, unknown, unknown, unknown, unknown >({ getRootHostContext: () => ({ isInsideText: false, }), prepareForCommit: () => null, preparePortalMount: () => null, clearContainer: () => false, resetAfterCommit(rootNode) { if (typeof rootNode.onComputeLayout === 'function') { rootNode.onComputeLayout(); } emitLayoutListeners(rootNode); // Since renders are throttled at the instance level and component children // are rendered only once and then get deleted, we need an escape hatch to // trigger an immediate render to ensure children are written to output before they get erased if (rootNode.isStaticDirty) { rootNode.isStaticDirty = false; if (typeof rootNode.onImmediateRender === 'function') { rootNode.onImmediateRender(); } return; } if (typeof rootNode.onRender === 'function') { rootNode.onRender(); } }, getChildHostContext(parentHostContext, type) { const previousIsInsideText = parentHostContext.isInsideText; const isInsideText = type === 'ink-text' || type === 'ink-virtual-text'; if (previousIsInsideText === isInsideText) { return parentHostContext; } return {isInsideText}; }, shouldSetTextContent: () => false, createInstance(originalType, newProps, rootNode, hostContext) { if (hostContext.isInsideText && originalType === 'ink-box') { throw new Error(` can’t be nested inside component`); } const type = originalType === 'ink-text' && hostContext.isInsideText ? 'ink-virtual-text' : originalType; const node = createNode(type); for (const [key, value] of Object.entries(newProps)) { if (key === 'children') { continue; } if (key === 'style') { setStyle(node, value as Styles); if (node.yogaNode) { applyStyles(node.yogaNode, value as Styles); } continue; } if (key === 'internal_transform') { node.internal_transform = value as OutputTransformer; continue; } if (key === 'internal_static') { currentRootNode = rootNode; node.internal_static = true; rootNode.isStaticDirty = true; // Save reference to node to skip traversal of entire // node tree to find it rootNode.staticNode = node; continue; } setAttribute(node, key, value as DOMNodeAttribute); } return node; }, createTextInstance(text, _root, hostContext) { if (!hostContext.isInsideText) { throw new Error( `Text string "${text}" must be rendered inside component`, ); } return createTextNode(text); }, resetTextContent() {}, hideTextInstance(node) { setTextNodeValue(node, ''); }, unhideTextInstance(node, text) { setTextNodeValue(node, text); }, getPublicInstance: instance => instance, hideInstance(node) { node.yogaNode?.setDisplay(Yoga.DISPLAY_NONE); }, unhideInstance(node) { node.yogaNode?.setDisplay(Yoga.DISPLAY_FLEX); }, appendInitialChild: appendChildNode, appendChild: appendChildNode, insertBefore: insertBeforeNode, finalizeInitialChildren() { return false; }, isPrimaryRenderer: true, supportsMutation: true, supportsPersistence: false, 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, cancelTimeout: clearTimeout, noTimeout: -1, beforeActiveInstanceBlur() {}, afterActiveInstanceBlur() {}, detachDeletedInstance() {}, getInstanceFromNode: () => null, prepareScopeUpdate() {}, getInstanceFromScope: () => null, appendChildToContainer: appendChildNode, insertInContainerBefore: insertBeforeNode, removeChildFromContainer(node, removeNode) { removeChildNode(node, removeNode); cleanupYogaNode(removeNode.yogaNode); }, commitUpdate(node, _type, oldProps, newProps) { if (currentRootNode && node.internal_static) { currentRootNode.isStaticDirty = true; } const props = diff(oldProps, newProps); const style = diff( oldProps['style'] as Styles, newProps['style'] as Styles, ); if (!props && !style) { return; } if (props) { for (const [key, value] of Object.entries(props)) { if (key === 'style') { setStyle(node, value as Styles); continue; } if (key === 'internal_transform') { node.internal_transform = value as OutputTransformer; continue; } if (key === 'internal_static') { node.internal_static = true; continue; } setAttribute(node, key, value as DOMNodeAttribute); } } if (style && node.yogaNode) { applyStyles( node.yogaNode, style, (newProps['style'] as Styles | undefined) ?? {}, ); } }, commitTextUpdate(node, _oldText, newText) { setTextNodeValue(node, newText); }, removeChild(node, removeNode) { removeChildNode(node, removeNode); cleanupYogaNode(removeNode.yogaNode); }, setCurrentUpdatePriority(newPriority: number) { currentUpdatePriority = newPriority; }, getCurrentUpdatePriority: () => currentUpdatePriority, resolveUpdatePriority() { if (currentUpdatePriority !== NoEventPriority) { return currentUpdatePriority; } return DefaultEventPriority; }, maySuspendCommit() { // Return true to enable Suspense resource preloading return true; }, // eslint-disable-next-line @typescript-eslint/naming-convention NotPendingTransition: undefined, // eslint-disable-next-line @typescript-eslint/naming-convention HostTransitionContext: createContext( null, ) as unknown as ReactContext, resetFormInstance() {}, requestPostPaintCallback() {}, shouldAttemptEagerTransition() { return false; }, trackSchedulerEvent() {}, resolveEventType() { return null; }, resolveEventTimeStamp() { return -1.1; }, preloadInstance() { return true; }, startSuspendingCommit() {}, suspendInstance() {}, waitForCommitToBeReady() { return null; }, rendererPackageName: packageInfo.name, rendererVersion: packageInfo.version, }); ================================================ FILE: src/render-background.ts ================================================ import colorize from './colorize.js'; import {type DOMNode} from './dom.js'; import type Output from './output.js'; const renderBackground = ( x: number, y: number, node: DOMNode, output: Output, ): void => { if (!node.style.backgroundColor) { return; } const width = node.yogaNode!.getComputedWidth(); const height = node.yogaNode!.getComputedHeight(); // Calculate the actual content area considering borders const leftBorderWidth = node.style.borderStyle && node.style.borderLeft !== false ? 1 : 0; const rightBorderWidth = node.style.borderStyle && node.style.borderRight !== false ? 1 : 0; const topBorderHeight = node.style.borderStyle && node.style.borderTop !== false ? 1 : 0; const bottomBorderHeight = node.style.borderStyle && node.style.borderBottom !== false ? 1 : 0; const contentWidth = width - leftBorderWidth - rightBorderWidth; const contentHeight = height - topBorderHeight - bottomBorderHeight; if (!(contentWidth > 0 && contentHeight > 0)) { return; } // Create background fill for each row const backgroundLine = colorize( ' '.repeat(contentWidth), node.style.backgroundColor, 'background', ); for (let row = 0; row < contentHeight; row++) { output.write( x + leftBorderWidth, y + topBorderHeight + row, backgroundLine, {transformers: []}, ); } }; export default renderBackground; ================================================ FILE: src/render-border.ts ================================================ import cliBoxes from 'cli-boxes'; import chalk from 'chalk'; import colorize from './colorize.js'; import {type DOMNode} from './dom.js'; import type Output from './output.js'; const renderBorder = ( x: number, y: number, node: DOMNode, output: Output, ): void => { if (node.style.borderStyle) { const width = node.yogaNode!.getComputedWidth(); const height = node.yogaNode!.getComputedHeight(); const box = typeof node.style.borderStyle === 'string' ? cliBoxes[node.style.borderStyle] : node.style.borderStyle; const topBorderColor = node.style.borderTopColor ?? node.style.borderColor; const bottomBorderColor = node.style.borderBottomColor ?? node.style.borderColor; const leftBorderColor = node.style.borderLeftColor ?? node.style.borderColor; const rightBorderColor = node.style.borderRightColor ?? node.style.borderColor; const dimTopBorderColor = node.style.borderTopDimColor ?? node.style.borderDimColor; const dimBottomBorderColor = node.style.borderBottomDimColor ?? node.style.borderDimColor; const dimLeftBorderColor = node.style.borderLeftDimColor ?? node.style.borderDimColor; const dimRightBorderColor = node.style.borderRightDimColor ?? node.style.borderDimColor; const showTopBorder = node.style.borderTop !== false; const showBottomBorder = node.style.borderBottom !== false; const showLeftBorder = node.style.borderLeft !== false; const showRightBorder = node.style.borderRight !== false; const contentWidth = width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0); let topBorder = showTopBorder ? colorize( (showLeftBorder ? box.topLeft : '') + box.top.repeat(contentWidth) + (showRightBorder ? box.topRight : ''), topBorderColor, 'foreground', ) : undefined; if (showTopBorder && dimTopBorderColor) { topBorder = chalk.dim(topBorder); } let verticalBorderHeight = height; if (showTopBorder) { verticalBorderHeight -= 1; } if (showBottomBorder) { verticalBorderHeight -= 1; } let leftBorder = ( colorize(box.left, leftBorderColor, 'foreground') + '\n' ).repeat(verticalBorderHeight); if (dimLeftBorderColor) { leftBorder = chalk.dim(leftBorder); } let rightBorder = ( colorize(box.right, rightBorderColor, 'foreground') + '\n' ).repeat(verticalBorderHeight); if (dimRightBorderColor) { rightBorder = chalk.dim(rightBorder); } let bottomBorder = showBottomBorder ? colorize( (showLeftBorder ? box.bottomLeft : '') + box.bottom.repeat(contentWidth) + (showRightBorder ? box.bottomRight : ''), bottomBorderColor, 'foreground', ) : undefined; if (showBottomBorder && dimBottomBorderColor) { bottomBorder = chalk.dim(bottomBorder); } const offsetY = showTopBorder ? 1 : 0; if (topBorder) { output.write(x, y, topBorder, {transformers: []}); } if (showLeftBorder) { output.write(x, y + offsetY, leftBorder, {transformers: []}); } if (showRightBorder) { output.write(x + width - 1, y + offsetY, rightBorder, { transformers: [], }); } if (bottomBorder) { output.write(x, y + height - 1, bottomBorder, {transformers: []}); } } }; export default renderBorder; ================================================ FILE: src/render-node-to-output.ts ================================================ import widestLine from 'widest-line'; import indentString from 'indent-string'; import Yoga from 'yoga-layout'; import wrapText from './wrap-text.js'; import getMaxWidth from './get-max-width.js'; import squashTextNodes from './squash-text-nodes.js'; import renderBorder from './render-border.js'; import renderBackground from './render-background.js'; import {type DOMElement} from './dom.js'; import type Output from './output.js'; // If parent container is ``, text nodes will be treated as separate nodes in // the tree and will have their own coordinates in the layout. // To ensure text nodes are aligned correctly, take X and Y of the first text node // and use it as offset for the rest of the nodes // Only first node is taken into account, because other text nodes can't have margin or padding, // so their coordinates will be relative to the first node anyway const applyPaddingToText = (node: DOMElement, text: string): string => { const yogaNode = node.childNodes[0]?.yogaNode; if (yogaNode) { const offsetX = yogaNode.getComputedLeft(); const offsetY = yogaNode.getComputedTop(); text = '\n'.repeat(offsetY) + indentString(text, offsetX); } return text; }; export type OutputTransformer = (s: string, index: number) => string; export const renderNodeToScreenReaderOutput = ( node: DOMElement, options: { parentRole?: string; skipStaticElements?: boolean; } = {}, ): string => { if (options.skipStaticElements && node.internal_static) { return ''; } if (node.yogaNode?.getDisplay() === Yoga.DISPLAY_NONE) { return ''; } let output = ''; if (node.nodeName === 'ink-text') { output = squashTextNodes(node); } else if (node.nodeName === 'ink-box' || node.nodeName === 'ink-root') { const separator = node.style.flexDirection === 'row' || node.style.flexDirection === 'row-reverse' ? ' ' : '\n'; const childNodes = node.style.flexDirection === 'row-reverse' || node.style.flexDirection === 'column-reverse' ? [...node.childNodes].reverse() : [...node.childNodes]; output = childNodes .map(childNode => { const screenReaderOutput = renderNodeToScreenReaderOutput( childNode as DOMElement, { parentRole: node.internal_accessibility?.role, skipStaticElements: options.skipStaticElements, }, ); return screenReaderOutput; }) .filter(Boolean) .join(separator); } if (node.internal_accessibility) { const {role, state} = node.internal_accessibility; if (state) { const stateKeys = Object.keys(state) as Array; const stateDescription = stateKeys.filter(key => state[key]).join(', '); if (stateDescription) { output = `(${stateDescription}) ${output}`; } } if (role && role !== options.parentRole) { output = `${role}: ${output}`; } } return output; }; // After nodes are laid out, render each to output object, which later gets rendered to terminal const renderNodeToOutput = ( node: DOMElement, output: Output, options: { offsetX?: number; offsetY?: number; transformers?: OutputTransformer[]; skipStaticElements: boolean; }, ) => { const { offsetX = 0, offsetY = 0, transformers = [], skipStaticElements, } = options; if (skipStaticElements && node.internal_static) { return; } const {yogaNode} = node; if (yogaNode) { if (yogaNode.getDisplay() === Yoga.DISPLAY_NONE) { return; } // Left and top positions in Yoga are relative to their parent node const x = offsetX + yogaNode.getComputedLeft(); const y = offsetY + yogaNode.getComputedTop(); // Transformers are functions that transform final text output of each component // See Output class for logic that applies transformers let newTransformers = transformers; if (typeof node.internal_transform === 'function') { newTransformers = [node.internal_transform, ...transformers]; } if (node.nodeName === 'ink-text') { let text = squashTextNodes(node); if (text.length > 0) { const currentWidth = widestLine(text); const maxWidth = getMaxWidth(yogaNode); if (currentWidth > maxWidth) { const textWrap = node.style.textWrap ?? 'wrap'; text = wrapText(text, maxWidth, textWrap); } text = applyPaddingToText(node, text); output.write(x, y, text, {transformers: newTransformers}); } return; } let clipped = false; if (node.nodeName === 'ink-box') { renderBackground(x, y, node, output); renderBorder(x, y, node, output); const clipHorizontally = node.style.overflowX === 'hidden' || node.style.overflow === 'hidden'; const clipVertically = node.style.overflowY === 'hidden' || node.style.overflow === 'hidden'; if (clipHorizontally || clipVertically) { const x1 = clipHorizontally ? x + yogaNode.getComputedBorder(Yoga.EDGE_LEFT) : undefined; const x2 = clipHorizontally ? x + yogaNode.getComputedWidth() - yogaNode.getComputedBorder(Yoga.EDGE_RIGHT) : undefined; const y1 = clipVertically ? y + yogaNode.getComputedBorder(Yoga.EDGE_TOP) : undefined; const y2 = clipVertically ? y + yogaNode.getComputedHeight() - yogaNode.getComputedBorder(Yoga.EDGE_BOTTOM) : undefined; output.clip({x1, x2, y1, y2}); clipped = true; } } if (node.nodeName === 'ink-root' || node.nodeName === 'ink-box') { for (const childNode of node.childNodes) { renderNodeToOutput(childNode as DOMElement, output, { offsetX: x, offsetY: y, transformers: newTransformers, skipStaticElements, }); } if (clipped) { output.unclip(); } } } }; export default renderNodeToOutput; ================================================ FILE: src/render-to-string.ts ================================================ import type {ReactNode} from 'react'; import Yoga from 'yoga-layout'; import {LegacyRoot} from 'react-reconciler/constants.js'; import reconciler from './reconciler.js'; import renderer from './renderer.js'; import {createNode, type DOMElement} from './dom.js'; export type RenderToStringOptions = { /** Width of the virtual terminal in columns. @default 80 */ columns?: number; }; /** Render a React element to a string synchronously. Unlike `render()`, this function does not write to stdout, does not set up any terminal event listeners, and returns the rendered output as a string. Useful for generating documentation, writing output to files, testing, or any scenario where you need the rendered output as a string without starting a persistent terminal application. **Notes:** - Terminal-specific hooks (`useInput`, `useStdin`, `useStdout`, `useStderr`, `useApp`, `useFocus`, `useFocusManager`) return default no-op values since there is no terminal session. They will not throw, but they will not function as in a live terminal. - `useEffect` callbacks will execute during rendering (due to synchronous rendering mode), but state updates they trigger will not affect the returned output, which reflects the initial render. - `useLayoutEffect` callbacks fire synchronously during commit, so state updates they trigger **will** be reflected in the output. - The `` component is supported — its output is prepended to the dynamic output. - If a component throws during rendering, the error is propagated to the caller after cleanup. @example ``` import {renderToString, Text, Box} from 'ink'; const output = renderToString( Hello World , {columns: 40} ); console.log(output); ``` */ const renderToString = ( node: ReactNode, options?: RenderToStringOptions, ): string => { const columns = options?.columns ?? 80; // Create a standalone root node — no stdout, stdin, or terminal bindings const rootNode: DOMElement = createNode('ink-root'); // Capture static output from intermediate renders. // The component uses useLayoutEffect to clear its children after // the first commit. The reconciler's resetAfterCommit calls onImmediateRender // when static content is dirty (and returns early, skipping the normal // onRender callback), giving us a chance to capture it before it's cleared // by the subsequent re-render. let capturedStaticOutput = ''; rootNode.onComputeLayout = () => { rootNode.yogaNode!.setWidth(columns); rootNode.yogaNode!.calculateLayout( undefined, undefined, Yoga.DIRECTION_LTR, ); }; rootNode.onImmediateRender = () => { const {staticOutput} = renderer(rootNode, false); if (staticOutput && staticOutput !== '\n') { capturedStaticOutput += staticOutput; } }; // Capture the first uncaught error so we can re-throw it after cleanup. // React's reconciler catches component errors internally and reports them // via onUncaughtError rather than letting them propagate. For a synchronous // utility like renderToString, callers expect errors to throw. let uncaughtError: unknown; // Create a reconciler container in legacy (synchronous) mode. // The four trailing callbacks are: onUncaughtError, onCaughtError, // onRecoverableError, and onHostTransitionComplete. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const container = reconciler.createContainer( rootNode, LegacyRoot, null, false, null, 'render-to-string', (error: unknown) => { uncaughtError ??= error; }, () => {}, () => {}, () => {}, ); let teardownSucceeded = false; try { // Synchronously render the React tree into the container reconciler.updateContainerSync(node, container, null, () => {}); reconciler.flushSyncWork(); // Yoga layout has already been calculated by onComputeLayout during commit. // Render the DOM tree to a string — this captures the dynamic (non-static) output. const {output} = renderer(rootNode, false); // Tear down: unmount the tree so the reconciler cleans up child nodes // and runs effect cleanup functions. Child Yoga nodes are freed by the // reconciler's removeChildFromContainer → cleanupYogaNode → freeRecursive. reconciler.updateContainerSync(null, container, null, () => {}); reconciler.flushSyncWork(); teardownSucceeded = true; // Free the root yoga node itself (children already freed by reconciler) rootNode.yogaNode!.free(); // Re-throw after full cleanup so callers see the original error. if (uncaughtError !== undefined) { throw uncaughtError instanceof Error ? uncaughtError : // eslint-disable-next-line @typescript-eslint/no-base-to-string new Error(String(uncaughtError)); } // The renderer appends a trailing newline to static output for terminal // rendering (so dynamic output starts on a fresh line). Strip it here // so renderToString returns clean output. const normalizedStaticOutput = capturedStaticOutput.endsWith('\n') ? capturedStaticOutput.slice(0, -1) : capturedStaticOutput; if (normalizedStaticOutput && output) { return normalizedStaticOutput + '\n' + output; } return normalizedStaticOutput || output; } finally { // Ensure native Yoga memory is freed even if rendering or teardown threw. // Yoga nodes are WASM-backed and not garbage collected. if (!teardownSucceeded && rootNode.yogaNode) { try { // If reconciler teardown failed, some child nodes may not have been // freed. Use freeRecursive to clean up the entire tree as best-effort. rootNode.yogaNode.freeRecursive(); } catch { // Best-effort: node may already be partially freed } } } }; export default renderToString; ================================================ FILE: src/render.ts ================================================ import {Stream} from 'node:stream'; import process from 'node:process'; import type {ReactNode} from 'react'; import Ink, {type Options as InkOptions, type RenderMetrics} from './ink.js'; import instances from './instances.js'; import {type KittyKeyboardOptions} from './kitty-keyboard.js'; export type RenderOptions = { /** Output stream where the app will be rendered. @default process.stdout */ stdout?: NodeJS.WriteStream; /** Input stream where app will listen for input. @default process.stdin */ stdin?: NodeJS.ReadStream; /** Error stream. @default process.stderr */ stderr?: NodeJS.WriteStream; /** If true, each update will be rendered as separate output, without replacing the previous one. @default false */ debug?: boolean; /** Configure whether Ink should listen for Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and the process is expected to handle it manually. @default true */ exitOnCtrlC?: boolean; /** Patch console methods to ensure console output doesn't mix with Ink's output. Note: Once unmount starts, Ink restores the native console before React cleanup runs. Teardown-time `console.*` output then follows the normal console behavior instead of being rerouted through Ink. @default true */ patchConsole?: boolean; /** Runs the given callback after each render and re-render with render metrics. Note: this callback runs after Ink commits a frame, but it does not wait for `stdout`/`stderr` stream callbacks. To run code after output is flushed, use `waitUntilRenderFlush()`. */ onRender?: (metrics: RenderMetrics) => void; /** Enable screen reader support. See https://github.com/vadimdemedes/ink/blob/master/readme.md#screen-reader-support @default process.env['INK_SCREEN_READER'] === 'true' */ isScreenReaderEnabled?: boolean; /** Maximum frames per second for render updates. This controls how frequently the UI can update to prevent excessive re-rendering. Higher values allow more frequent updates but may impact performance. @default 30 */ maxFps?: number; /** Enable incremental rendering mode which only updates changed lines instead of redrawing the entire output. This can reduce flickering and improve performance for frequently updating UIs. @default false */ 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. Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change the rendering mode or create a fresh instance. @default false */ 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; /** Override automatic interactive mode detection. By default, Ink detects whether the environment is interactive based on CI detection (via [`is-in-ci`](https://github.com/sindresorhus/is-in-ci)) and `stdout.isTTY`. Most users should not need to set this. When non-interactive, Ink disables ANSI erase sequences, cursor manipulation, synchronized output, resize handling, and kitty keyboard auto-detection, writing only the final frame at unmount. Set to `false` to force non-interactive mode or `true` to force interactive mode when the automatic detection doesn't suit your use case. Note: Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance. @default true (false if in CI or `stdout.isTTY` is falsy) */ interactive?: boolean; /** Render the app in the terminal's alternate screen buffer. When enabled, the app renders on a separate screen, and the original terminal content is restored when the app exits. This is the same mechanism used by programs like vim, htop, and less. Note: The terminal's scrollback buffer is not available while in the alternate screen. This is standard terminal behavior; programs like vim use the alternate screen specifically to avoid polluting the user's scrollback history. Note: Ink intentionally treats alternate-screen teardown output as disposable. It does not preserve or replay teardown-time frames, hook writes, or `console.*` output after restoring the primary screen. Only works in interactive mode. Ignored when `interactive` is `false` or in a non-interactive environment (CI, piped stdout). Note: Reusing the same stdout across multiple `render()` calls without unmounting is unsupported. Call `unmount()` first if you need to change this option or create a fresh instance. @default false */ alternateScreen?: boolean; }; export type Instance = { /** Replace the previous root node with a new one or update props of the current root node. */ rerender: Ink['render']; /** Manually unmount the whole Ink app. */ unmount: Ink['unmount']; /** Returns a promise that settles when the app is unmounted. It resolves with the value passed to `exit(value)` and rejects with the error passed to `exit(error)`. When `unmount()` is called manually, it settles after unmount-related stdout writes complete. @example ```jsx const {unmount, waitUntilExit} = render(); setTimeout(unmount, 1000); await waitUntilExit(); // resolves after `unmount()` is called ``` */ waitUntilExit: Ink['waitUntilExit']; /** Returns a promise that settles after pending render output is flushed to stdout. This can be used after `rerender()` when you need to run code only after the frame is written. @example ```jsx const {rerender, waitUntilRenderFlush} = render(); rerender(); await waitUntilRenderFlush(); // output for "ready" is flushed runNextCommand(); ``` */ waitUntilRenderFlush: Ink['waitUntilRenderFlush']; /** Unmount the current app and remove the internal Ink instance for this stdout. This is mostly useful for advanced cases where you need `render()` to create a fresh instance for the same stream without leaving terminal state such as the alternate screen behind. */ cleanup: () => void; /** Clear output. */ clear: () => void; }; /** Mount a component and render the output. */ const render = ( node: ReactNode, options?: NodeJS.WriteStream | RenderOptions, ): Instance => { const inkOptions: InkOptions = { stdout: process.stdout, stdin: process.stdin, stderr: process.stderr, debug: false, exitOnCtrlC: true, patchConsole: true, maxFps: 30, incrementalRendering: false, concurrent: false, alternateScreen: false, ...getOptions(options), }; const instance: Ink = getInstance( inkOptions.stdout, () => new Ink(inkOptions), ); instance.render(node); return { rerender: instance.render, unmount() { instance.unmount(); }, waitUntilExit: instance.waitUntilExit, waitUntilRenderFlush: instance.waitUntilRenderFlush, cleanup() { instance.unmount(); }, clear: instance.clear, }; }; export default render; const getOptions = ( stdout: NodeJS.WriteStream | RenderOptions | undefined = {}, ): RenderOptions => { if (stdout instanceof Stream) { return { stdout, stdin: process.stdin, }; } return stdout; }; const getInstance = ( stdout: NodeJS.WriteStream, createInstance: () => Ink, ): Ink => { const instance = instances.get(stdout); if (instance === undefined) { const newInstance = createInstance(); instances.set(stdout, newInstance); return newInstance; } // Ink keeps one live renderer per stdout. Reusing the same stream without // unmounting is unsupported, but return the existing instance so we don't // create two renderers that compete for the same output. Write the warning // directly to native stderr so an existing alternate-screen renderer cannot // swallow it via patchConsole. process.stderr.write( 'Warning: render() was called again for the same stdout before the previous Ink instance was unmounted. Reusing stdout across multiple render() calls is unsupported. Call unmount() first.\n', ); return instance; }; ================================================ FILE: src/renderer.ts ================================================ import renderNodeToOutput, { renderNodeToScreenReaderOutput, } from './render-node-to-output.js'; import Output from './output.js'; import {type DOMElement} from './dom.js'; type Result = { output: string; outputHeight: number; staticOutput: string; }; const renderer = (node: DOMElement, isScreenReaderEnabled: boolean): Result => { if (node.yogaNode) { if (isScreenReaderEnabled) { const output = renderNodeToScreenReaderOutput(node, { skipStaticElements: true, }); const outputHeight = output === '' ? 0 : output.split('\n').length; let staticOutput = ''; if (node.staticNode) { staticOutput = renderNodeToScreenReaderOutput(node.staticNode, { skipStaticElements: false, }); } return { output, outputHeight, staticOutput: staticOutput ? `${staticOutput}\n` : '', }; } const output = new Output({ width: node.yogaNode.getComputedWidth(), height: node.yogaNode.getComputedHeight(), }); renderNodeToOutput(node, output, { skipStaticElements: true, }); let staticOutput; if (node.staticNode?.yogaNode) { staticOutput = new Output({ width: node.staticNode.yogaNode.getComputedWidth(), height: node.staticNode.yogaNode.getComputedHeight(), }); renderNodeToOutput(node.staticNode, staticOutput, { skipStaticElements: false, }); } const {output: generatedOutput, height: outputHeight} = output.get(); return { output: generatedOutput, outputHeight, // Newline at the end is needed, because static output doesn't have one, so // interactive output will override last line of static output staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '', }; } return { output: '', outputHeight: 0, staticOutput: '', }; }; export default renderer; ================================================ FILE: src/sanitize-ansi.ts ================================================ import {hasAnsiControlCharacters, tokenizeAnsi} from './ansi-tokenizer.js'; const sgrParametersRegex = /^[\d:;]*$/; // Strip ANSI escape sequences that would conflict with Ink's layout. // Preserved: SGR sequences (colors, bold, etc. - end with 'm') and // OSC sequences (hyperlinks, etc. - ESC ] or C1 OSC). // Stripped: cursor movement, screen clearing, and other control sequences. const sanitizeAnsi = (text: string): string => { if (!hasAnsiControlCharacters(text)) { return text; } let output = ''; for (const token of tokenizeAnsi(text)) { if (token.type === 'text' || token.type === 'osc') { output += token.value; continue; } if ( token.type === 'csi' && token.finalCharacter === 'm' && token.intermediateString === '' && sgrParametersRegex.test(token.parameterString) ) { output += token.value; } } return output; }; export default sanitizeAnsi; ================================================ FILE: src/squash-text-nodes.ts ================================================ import {type DOMElement} from './dom.js'; import sanitizeAnsi from './sanitize-ansi.js'; // Squashing text nodes allows to combine multiple text nodes into one and write // to `Output` instance only once. For example, hello{' '}world // is actually 3 text nodes, which would result 3 writes to `Output`. // // Also, this is necessary for libraries like ink-link (https://github.com/sindresorhus/ink-link), // which need to wrap all children at once, instead of wrapping 3 text nodes separately. const squashTextNodes = (node: DOMElement): string => { let text = ''; for (let index = 0; index < node.childNodes.length; index++) { const childNode = node.childNodes[index]; if (childNode === undefined) { continue; } let nodeText = ''; if (childNode.nodeName === '#text') { nodeText = childNode.nodeValue; } else { if ( childNode.nodeName === 'ink-text' || childNode.nodeName === 'ink-virtual-text' ) { nodeText = squashTextNodes(childNode); } // Since these text nodes are being concatenated, `Output` instance won't be able to // apply children transform, so we have to do it manually here for each text node if ( nodeText.length > 0 && typeof childNode.internal_transform === 'function' ) { nodeText = childNode.internal_transform(nodeText, index); } } text += nodeText; } return sanitizeAnsi(text); }; export default squashTextNodes; ================================================ FILE: src/styles.ts ================================================ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import {type Boxes, type BoxStyle} from 'cli-boxes'; import {type LiteralUnion} from 'type-fest'; import {type ForegroundColorName} from 'ansi-styles'; // Note: We import directly from `ansi-styles` to avoid a bug in TypeScript. import Yoga, {type Node as YogaNode} from 'yoga-layout'; export type Styles = { readonly textWrap?: | 'wrap' | 'end' | 'middle' | 'truncate-end' | 'truncate' | 'truncate-middle' | 'truncate-start'; /** Controls how the element is positioned. When `position` is `static`, `top`, `right`, `bottom`, and `left` are ignored. */ readonly position?: 'absolute' | 'relative' | 'static'; /** Top offset for positioned elements. */ readonly top?: number | string; /** Right offset for positioned elements. */ readonly right?: number | string; /** Bottom offset for positioned elements. */ readonly bottom?: number | string; /** Left offset for positioned elements. */ readonly left?: number | string; /** Size of the gap between an element's columns. */ readonly columnGap?: number; /** Size of the gap between an element's rows. */ readonly rowGap?: number; /** Size of the gap between an element's columns and rows. A shorthand for `columnGap` and `rowGap`. */ readonly gap?: number; /** Margin on all sides. Equivalent to setting `marginTop`, `marginBottom`, `marginLeft`, and `marginRight`. */ readonly margin?: number; /** Horizontal margin. Equivalent to setting `marginLeft` and `marginRight`. */ readonly marginX?: number; /** Vertical margin. Equivalent to setting `marginTop` and `marginBottom`. */ readonly marginY?: number; /** Top margin. */ readonly marginTop?: number; /** Bottom margin. */ readonly marginBottom?: number; /** Left margin. */ readonly marginLeft?: number; /** Right margin. */ readonly marginRight?: number; /** Padding on all sides. Equivalent to setting `paddingTop`, `paddingBottom`, `paddingLeft`, and `paddingRight`. */ readonly padding?: number; /** Horizontal padding. Equivalent to setting `paddingLeft` and `paddingRight`. */ readonly paddingX?: number; /** Vertical padding. Equivalent to setting `paddingTop` and `paddingBottom`. */ readonly paddingY?: number; /** Top padding. */ readonly paddingTop?: number; /** Bottom padding. */ readonly paddingBottom?: number; /** Left padding. */ readonly paddingLeft?: number; /** Right padding. */ readonly paddingRight?: number; /** This property defines the ability for a flex item to grow if necessary. See [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/). */ readonly flexGrow?: number; /** It specifies the “flex shrink factor”, which determines how much the flex item will shrink relative to the rest of the flex items in the flex container when there isn’t enough space on the row. See [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/). */ readonly flexShrink?: number; /** It establishes the main-axis, thus defining the direction flex items are placed in the flex container. See [flex-direction](https://css-tricks.com/almanac/properties/f/flex-direction/). */ readonly flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'; /** It specifies the initial size of the flex item, before any available space is distributed according to the flex factors. See [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/). */ readonly flexBasis?: number | string; /** It defines whether the flex items are forced in a single line or can be flowed into multiple lines. If set to multiple lines, it also defines the cross-axis which determines the direction new lines are stacked in. See [flex-wrap](https://css-tricks.com/almanac/properties/f/flex-wrap/). */ readonly flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'; /** The align-items property defines the default behavior for how items are laid out along the cross axis (perpendicular to the main axis). See [align-items](https://css-tricks.com/almanac/properties/a/align-items/). */ readonly alignItems?: | 'flex-start' | 'center' | 'flex-end' | 'stretch' | 'baseline'; /** It makes possible to override the align-items value for specific flex items. See [align-self](https://css-tricks.com/almanac/properties/a/align-self/). */ readonly alignSelf?: | 'flex-start' | 'center' | 'flex-end' | 'auto' | 'stretch' | 'baseline'; /** It defines the alignment along the cross axis when there are multiple lines of flex items (when using flex-wrap). See [align-content](https://css-tricks.com/almanac/properties/a/align-content/). */ readonly alignContent?: | 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'space-between' | 'space-around' | 'space-evenly'; /** It defines the alignment along the main axis. See [justify-content](https://css-tricks.com/almanac/properties/j/justify-content/). */ readonly justifyContent?: | 'flex-start' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly' | 'center'; /** Width of the element in spaces. You can also set it as a percentage, which will calculate the width based on the width of the parent element. */ readonly width?: number | string; /** Height of the element in lines (rows). You can also set it as a percentage, which will calculate the height based on the height of the parent element. */ readonly height?: number | string; /** Sets a minimum width of the element. Percentages aren't supported yet; see https://github.com/facebook/yoga/issues/872. */ readonly minWidth?: number | string; /** Sets a minimum height of the element in lines (rows). You can also set it as a percentage, which will calculate the minimum height based on the height of the parent element. */ readonly minHeight?: number | string; /** Sets a maximum width of the element. Percentages aren't supported yet; see https://github.com/facebook/yoga/issues/872. */ readonly maxWidth?: number | string; /** Sets a maximum height of the element in lines (rows). You can also set it as a percentage, which will calculate the maximum height based on the height of the parent element. */ readonly maxHeight?: number | string; /** Defines the aspect ratio (width/height) for the element. Use it with at least one size constraint (`width`, `height`, `minHeight`, or `maxHeight`) so Ink can derive the missing dimension. */ readonly aspectRatio?: number; /** Set this property to `none` to hide the element. */ readonly display?: 'flex' | 'none'; /** Add a border with a specified style. If `borderStyle` is `undefined` (the default), no border will be added. */ readonly borderStyle?: keyof Boxes | BoxStyle; /** Determines whether the top border is visible. @default true */ readonly borderTop?: boolean; /** Determines whether the bottom border is visible. @default true */ readonly borderBottom?: boolean; /** Determines whether the left border is visible. @default true */ readonly borderLeft?: boolean; /** Determines whether the right border is visible. @default true */ readonly borderRight?: boolean; /** Change border color. A shorthand for setting `borderTopColor`, `borderRightColor`, `borderBottomColor`, and `borderLeftColor`. */ readonly borderColor?: LiteralUnion; /** Change the top border color. Accepts the same values as `color` in `Text` component. */ readonly borderTopColor?: LiteralUnion; /** Change the bottom border color. Accepts the same values as `color` in `Text` component. */ readonly borderBottomColor?: LiteralUnion; /** Change the left border color. Accepts the same values as `color` in `Text` component. */ readonly borderLeftColor?: LiteralUnion; /** Change the right border color. Accepts the same values as `color` in `Text` component. */ readonly borderRightColor?: LiteralUnion; /** Dim the border color. A shorthand for setting `borderTopDimColor`, `borderBottomDimColor`, `borderLeftDimColor`, and `borderRightDimColor`. @default false */ readonly borderDimColor?: boolean; /** Dim the top border color. @default false */ readonly borderTopDimColor?: boolean; /** Dim the bottom border color. @default false */ readonly borderBottomDimColor?: boolean; /** Dim the left border color. @default false */ readonly borderLeftDimColor?: boolean; /** Dim the right border color. @default false */ readonly borderRightDimColor?: boolean; /** Behavior for an element's overflow in both directions. @default 'visible' */ readonly overflow?: 'visible' | 'hidden'; /** Behavior for an element's overflow in the horizontal direction. @default 'visible' */ readonly overflowX?: 'visible' | 'hidden'; /** Behavior for an element's overflow in the vertical direction. @default 'visible' */ readonly overflowY?: 'visible' | 'hidden'; /** Background color for the element. Accepts the same values as `color` in the `` component. */ readonly backgroundColor?: LiteralUnion; }; const positionEdges = [ ['top', Yoga.EDGE_TOP], ['right', Yoga.EDGE_RIGHT], ['bottom', Yoga.EDGE_BOTTOM], ['left', Yoga.EDGE_LEFT], ] as const; const applyPositionStyles = (node: YogaNode, style: Styles): void => { if ('position' in style) { let positionType = Yoga.POSITION_TYPE_RELATIVE; if (style.position === 'absolute') { positionType = Yoga.POSITION_TYPE_ABSOLUTE; } else if (style.position === 'static') { positionType = Yoga.POSITION_TYPE_STATIC; } node.setPositionType(positionType); } for (const [property, edge] of positionEdges) { if (!(property in style)) { continue; } const value = style[property]; if (typeof value === 'string') { node.setPositionPercent(edge, Number.parseFloat(value)); continue; } node.setPosition(edge, value); } }; const applyMarginStyles = (node: YogaNode, style: Styles): void => { if ('margin' in style) { node.setMargin(Yoga.EDGE_ALL, style.margin ?? 0); } if ('marginX' in style) { node.setMargin(Yoga.EDGE_HORIZONTAL, style.marginX ?? 0); } if ('marginY' in style) { node.setMargin(Yoga.EDGE_VERTICAL, style.marginY ?? 0); } if ('marginLeft' in style) { node.setMargin(Yoga.EDGE_START, style.marginLeft || 0); } if ('marginRight' in style) { node.setMargin(Yoga.EDGE_END, style.marginRight || 0); } if ('marginTop' in style) { node.setMargin(Yoga.EDGE_TOP, style.marginTop || 0); } if ('marginBottom' in style) { node.setMargin(Yoga.EDGE_BOTTOM, style.marginBottom || 0); } }; const applyPaddingStyles = (node: YogaNode, style: Styles): void => { if ('padding' in style) { node.setPadding(Yoga.EDGE_ALL, style.padding ?? 0); } if ('paddingX' in style) { node.setPadding(Yoga.EDGE_HORIZONTAL, style.paddingX ?? 0); } if ('paddingY' in style) { node.setPadding(Yoga.EDGE_VERTICAL, style.paddingY ?? 0); } if ('paddingLeft' in style) { node.setPadding(Yoga.EDGE_LEFT, style.paddingLeft || 0); } if ('paddingRight' in style) { node.setPadding(Yoga.EDGE_RIGHT, style.paddingRight || 0); } if ('paddingTop' in style) { node.setPadding(Yoga.EDGE_TOP, style.paddingTop || 0); } if ('paddingBottom' in style) { node.setPadding(Yoga.EDGE_BOTTOM, style.paddingBottom || 0); } }; const applyFlexStyles = (node: YogaNode, style: Styles): void => { if ('flexGrow' in style) { node.setFlexGrow(style.flexGrow ?? 0); } if ('flexShrink' in style) { node.setFlexShrink( typeof style.flexShrink === 'number' ? style.flexShrink : 1, ); } if ('flexWrap' in style) { if (style.flexWrap === 'nowrap') { node.setFlexWrap(Yoga.WRAP_NO_WRAP); } if (style.flexWrap === 'wrap') { node.setFlexWrap(Yoga.WRAP_WRAP); } if (style.flexWrap === 'wrap-reverse') { node.setFlexWrap(Yoga.WRAP_WRAP_REVERSE); } } if ('flexDirection' in style) { if (style.flexDirection === 'row') { node.setFlexDirection(Yoga.FLEX_DIRECTION_ROW); } if (style.flexDirection === 'row-reverse') { node.setFlexDirection(Yoga.FLEX_DIRECTION_ROW_REVERSE); } if (style.flexDirection === 'column') { node.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN); } if (style.flexDirection === 'column-reverse') { node.setFlexDirection(Yoga.FLEX_DIRECTION_COLUMN_REVERSE); } } if ('flexBasis' in style) { if (typeof style.flexBasis === 'number') { node.setFlexBasis(style.flexBasis); } else if (typeof style.flexBasis === 'string') { node.setFlexBasisPercent(Number.parseInt(style.flexBasis, 10)); } else { // This should be replaced with node.setFlexBasisAuto() when new Yoga release is out node.setFlexBasis(Number.NaN); } } if ('alignItems' in style) { if (style.alignItems === 'stretch' || !style.alignItems) { node.setAlignItems(Yoga.ALIGN_STRETCH); } if (style.alignItems === 'flex-start') { node.setAlignItems(Yoga.ALIGN_FLEX_START); } if (style.alignItems === 'center') { node.setAlignItems(Yoga.ALIGN_CENTER); } if (style.alignItems === 'flex-end') { node.setAlignItems(Yoga.ALIGN_FLEX_END); } if (style.alignItems === 'baseline') { node.setAlignItems(Yoga.ALIGN_BASELINE); } } if ('alignSelf' in style) { if (style.alignSelf === 'auto' || !style.alignSelf) { node.setAlignSelf(Yoga.ALIGN_AUTO); } if (style.alignSelf === 'flex-start') { node.setAlignSelf(Yoga.ALIGN_FLEX_START); } if (style.alignSelf === 'center') { node.setAlignSelf(Yoga.ALIGN_CENTER); } if (style.alignSelf === 'flex-end') { node.setAlignSelf(Yoga.ALIGN_FLEX_END); } if (style.alignSelf === 'stretch') { node.setAlignSelf(Yoga.ALIGN_STRETCH); } if (style.alignSelf === 'baseline') { node.setAlignSelf(Yoga.ALIGN_BASELINE); } } if ('alignContent' in style) { // Keep wrapped lines top-packed by default; stretch can add surprising empty rows in fixed-height boxes. if (style.alignContent === 'flex-start' || !style.alignContent) { node.setAlignContent(Yoga.ALIGN_FLEX_START); } if (style.alignContent === 'center') { node.setAlignContent(Yoga.ALIGN_CENTER); } if (style.alignContent === 'flex-end') { node.setAlignContent(Yoga.ALIGN_FLEX_END); } if (style.alignContent === 'space-between') { node.setAlignContent(Yoga.ALIGN_SPACE_BETWEEN); } if (style.alignContent === 'space-around') { node.setAlignContent(Yoga.ALIGN_SPACE_AROUND); } if (style.alignContent === 'space-evenly') { node.setAlignContent(Yoga.ALIGN_SPACE_EVENLY); } if (style.alignContent === 'stretch') { node.setAlignContent(Yoga.ALIGN_STRETCH); } } if ('justifyContent' in style) { if (style.justifyContent === 'flex-start' || !style.justifyContent) { node.setJustifyContent(Yoga.JUSTIFY_FLEX_START); } if (style.justifyContent === 'center') { node.setJustifyContent(Yoga.JUSTIFY_CENTER); } if (style.justifyContent === 'flex-end') { node.setJustifyContent(Yoga.JUSTIFY_FLEX_END); } if (style.justifyContent === 'space-between') { node.setJustifyContent(Yoga.JUSTIFY_SPACE_BETWEEN); } if (style.justifyContent === 'space-around') { node.setJustifyContent(Yoga.JUSTIFY_SPACE_AROUND); } if (style.justifyContent === 'space-evenly') { node.setJustifyContent(Yoga.JUSTIFY_SPACE_EVENLY); } } }; const applyDimensionStyles = (node: YogaNode, style: Styles): void => { if ('width' in style) { if (typeof style.width === 'number') { node.setWidth(style.width); } else if (typeof style.width === 'string') { node.setWidthPercent(Number.parseInt(style.width, 10)); } else { node.setWidthAuto(); } } if ('height' in style) { if (typeof style.height === 'number') { node.setHeight(style.height); } else if (typeof style.height === 'string') { node.setHeightPercent(Number.parseInt(style.height, 10)); } else { node.setHeightAuto(); } } if ('minWidth' in style) { if (typeof style.minWidth === 'string') { node.setMinWidthPercent(Number.parseInt(style.minWidth, 10)); } else { node.setMinWidth(style.minWidth ?? 0); } } if ('minHeight' in style) { if (typeof style.minHeight === 'string') { node.setMinHeightPercent(Number.parseInt(style.minHeight, 10)); } else { node.setMinHeight(style.minHeight ?? 0); } } if ('maxWidth' in style) { if (typeof style.maxWidth === 'string') { node.setMaxWidthPercent(Number.parseInt(style.maxWidth, 10)); } else { node.setMaxWidth(style.maxWidth); } } if ('maxHeight' in style) { if (typeof style.maxHeight === 'string') { node.setMaxHeightPercent(Number.parseInt(style.maxHeight, 10)); } else { node.setMaxHeight(style.maxHeight); } } if ('aspectRatio' in style) { node.setAspectRatio(style.aspectRatio); } }; const applyDisplayStyles = (node: YogaNode, style: Styles): void => { if ('display' in style) { node.setDisplay( style.display === 'flex' ? Yoga.DISPLAY_FLEX : Yoga.DISPLAY_NONE, ); } }; const applyBorderStyles = ( node: YogaNode, style: Styles, currentStyle: Styles, ): void => { const hasBorderChanges = 'borderStyle' in style || 'borderTop' in style || 'borderBottom' in style || 'borderLeft' in style || 'borderRight' in style; if (!hasBorderChanges) { return; } const borderWidth = currentStyle.borderStyle ? 1 : 0; node.setBorder( Yoga.EDGE_TOP, currentStyle.borderTop === false ? 0 : borderWidth, ); node.setBorder( Yoga.EDGE_BOTTOM, currentStyle.borderBottom === false ? 0 : borderWidth, ); node.setBorder( Yoga.EDGE_LEFT, currentStyle.borderLeft === false ? 0 : borderWidth, ); node.setBorder( Yoga.EDGE_RIGHT, currentStyle.borderRight === false ? 0 : borderWidth, ); }; const applyGapStyles = (node: YogaNode, style: Styles): void => { if ('gap' in style) { node.setGap(Yoga.GUTTER_ALL, style.gap ?? 0); } if ('columnGap' in style) { node.setGap(Yoga.GUTTER_COLUMN, style.columnGap ?? 0); } if ('rowGap' in style) { node.setGap(Yoga.GUTTER_ROW, style.rowGap ?? 0); } }; const styles = ( node: YogaNode, style: Styles = {}, currentStyle: Styles = style, ): void => { applyPositionStyles(node, style); applyMarginStyles(node, style); applyPaddingStyles(node, style); applyFlexStyles(node, style); applyDimensionStyles(node, style); applyDisplayStyles(node, style); applyBorderStyles(node, style, currentStyle); applyGapStyles(node, style); }; export default styles; ================================================ FILE: src/utils.ts ================================================ import terminalSize from 'terminal-size'; /** Get the effective terminal dimensions from the given stdout stream. Falls back to `terminal-size` for columns in piped processes where `stdout.columns` is 0, and uses standard defaults (80×24) when dimensions cannot be determined. */ export const getWindowSize = ( stdout: NodeJS.WriteStream, ): {columns: number; rows: number} => { // `stdout.columns`/`rows` can be 0 or undefined in non-TTY environments. const {columns, rows} = stdout; if (columns && rows) { return {columns, rows}; } const fallbackSize = terminalSize(); return { columns: columns || fallbackSize.columns || 80, rows: rows || fallbackSize.rows || 24, }; }; ================================================ FILE: src/wrap-text.ts ================================================ import wrapAnsi from 'wrap-ansi'; import cliTruncate from 'cli-truncate'; import {type Styles} from './styles.js'; const cache: Record = {}; const wrapText = ( text: string, maxWidth: number, wrapType: Styles['textWrap'], ): string => { const cacheKey = text + String(maxWidth) + String(wrapType); const cachedText = cache[cacheKey]; if (cachedText) { return cachedText; } let wrappedText = text; if (wrapType === 'wrap') { wrappedText = wrapAnsi(text, maxWidth, { trim: false, hard: true, }); } if (wrapType!.startsWith('truncate')) { let position: 'end' | 'middle' | 'start' = 'end'; if (wrapType === 'truncate-middle') { position = 'middle'; } if (wrapType === 'truncate-start') { position = 'start'; } wrappedText = cliTruncate(text, maxWidth, {position}); } cache[cacheKey] = wrappedText; return wrappedText; }; export default wrapText; ================================================ FILE: src/write-synchronized.ts ================================================ import {type Writable} from 'node:stream'; import isInCi from 'is-in-ci'; export const bsu = '\u001B[?2026h'; export const esu = '\u001B[?2026l'; export function shouldSynchronize( stream: Writable, interactive?: boolean, ): boolean { return ( 'isTTY' in stream && (stream as Writable & {isTTY: boolean}).isTTY && (interactive ?? !isInCi) ); } ================================================ FILE: test/alternate-screen-example.tsx ================================================ import {spawn as spawnProcess} from 'node:child_process'; import * as path from 'node:path'; import url from 'node:url'; import test from 'ava'; import {gameReducer} from '../examples/alternate-screen/alternate-screen.js'; const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); test('snake can move into the tail cell when the tail moves away', t => { const state = { snake: [ {x: 2, y: 1}, {x: 1, y: 1}, {x: 1, y: 2}, {x: 2, y: 2}, ], food: {x: 0, y: 0}, score: 3, gameOver: false, won: false, frame: 10, }; const nextState = gameReducer(state, { type: 'tick', direction: 'down', }); t.false(nextState.gameOver); t.deepEqual(nextState.snake, [ {x: 2, y: 2}, {x: 2, y: 1}, {x: 1, y: 1}, {x: 1, y: 2}, ]); t.is(nextState.score, state.score); }); test('snake ends with a win when it fills the board', async t => { const fixturePath = path.join( __dirname, 'fixtures/alternate-screen-full-board-win.tsx', ); const childProcess = spawnProcess('node', ['--import=tsx', fixturePath], { cwd: __dirname, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; if (!childProcess.stdout || !childProcess.stderr) { t.fail('Fixture process did not expose stdout/stderr pipes'); return; } childProcess.stdout.on('data', (data: Uint8Array | string) => { stdout += typeof data === 'string' ? data : data.toString(); }); childProcess.stderr.on('data', (data: Uint8Array | string) => { stderr += typeof data === 'string' ? data : data.toString(); }); const result = await new Promise< {timedOut: true} | {timedOut: false; exitCode: number} >((resolve, reject) => { const timeout = setTimeout(() => { childProcess.kill(); resolve({timedOut: true}); }, 1000); childProcess.on('error', error => { clearTimeout(timeout); reject(error); }); childProcess.on('close', exitCode => { clearTimeout(timeout); resolve({timedOut: false, exitCode: exitCode ?? 0}); }); }); if (result.timedOut) { t.fail('Fixture hung instead of finishing the full-board win case'); return; } t.is(result.exitCode, 0, `Fixture exited with stderr: ${stderr}`); const nextState = JSON.parse(stdout) as { gameOver: boolean; won: boolean; score: number; snakeLength: number; }; t.true(nextState.gameOver); t.true(nextState.won); t.is(nextState.score, 297); t.is(nextState.snakeLength, 300); }); ================================================ FILE: test/ansi-tokenizer.ts ================================================ import test from 'ava'; import {tokenizeAnsi} from '../src/ansi-tokenizer.js'; test('tokenize plain text', t => { t.deepEqual(tokenizeAnsi('hello'), [{type: 'text', value: 'hello'}]); }); test('tokenize ESC CSI SGR sequence', t => { const tokens = tokenizeAnsi('A\u001B[31mB'); t.deepEqual( tokens.map(token => token.type), ['text', 'csi', 'text'], ); t.deepEqual(tokens[0], {type: 'text', value: 'A'}); t.deepEqual(tokens[2], {type: 'text', value: 'B'}); const csiToken = tokens[1]; if (csiToken?.type !== 'csi') { t.fail(); return; } t.is(csiToken.value, '\u001B[31m'); t.is(csiToken.parameterString, '31'); t.is(csiToken.intermediateString, ''); t.is(csiToken.finalCharacter, 'm'); }); test('tokenize C1 CSI sequence', t => { const tokens = tokenizeAnsi('A\u009B2 qB'); const csiToken = tokens[1]; if (csiToken?.type !== 'csi') { t.fail(); return; } t.is(csiToken.value, '\u009B2 q'); t.is(csiToken.parameterString, '2'); t.is(csiToken.intermediateString, ' '); t.is(csiToken.finalCharacter, 'q'); }); test('tokenize OSC control string with ST terminator', t => { const tokens = tokenizeAnsi('A\u001B]8;;https://example.com\u001B\\B'); const oscToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'osc', 'text'], ); if (oscToken?.type !== 'osc') { t.fail(); return; } t.is(oscToken.value, '\u001B]8;;https://example.com\u001B\\'); }); test('tokenize tmux DCS passthrough as one control string token', t => { const tokens = tokenizeAnsi( 'A\u001BPtmux;\u001B\u001B]8;;https://example.com\u001B\u001B\\\u001B\\B', ); const dcsToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'dcs', 'text'], ); if (dcsToken?.type !== 'dcs') { t.fail(); return; } t.true(dcsToken.value.startsWith('\u001BPtmux;')); t.true(dcsToken.value.endsWith('\u001B\\')); }); test('tokenize incomplete CSI as invalid and stop', t => { const tokens = tokenizeAnsi('A\u001B['); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'invalid', value: '\u001B['}, ]); }); test('tokenize incomplete ESC intermediate sequence as invalid and stop', t => { const tokens = tokenizeAnsi('A\u001B#'); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'invalid', value: '\u001B#'}, ]); }); test('ignore lone ESC before non-final byte', t => { const tokens = tokenizeAnsi('A\u001B\u0007B'); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'text', value: '\u0007B'}, ]); }); test('tokenize ESC ST sequence as ESC token', t => { const tokens = tokenizeAnsi('A\u001B\\B'); const escToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'esc', 'text'], ); if (escToken?.type !== 'esc') { t.fail(); return; } t.is(escToken.value, '\u001B\\'); t.is(escToken.intermediateString, ''); t.is(escToken.finalCharacter, '\\'); }); test('tokenize C1 OSC with C1 ST terminator', t => { const tokens = tokenizeAnsi('A\u009D8;;https://example.com\u009CB'); const oscToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'osc', 'text'], ); if (oscToken?.type !== 'osc') { t.fail(); return; } t.is(oscToken.value, '\u009D8;;https://example.com\u009C'); }); test('tokenize C1 OSC with ESC ST terminator', t => { const tokens = tokenizeAnsi('A\u009D8;;https://example.com\u001B\\B'); const oscToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'osc', 'text'], ); if (oscToken?.type !== 'osc') { t.fail(); return; } t.is(oscToken.value, '\u009D8;;https://example.com\u001B\\'); }); test('tokenize C1 SGR CSI sequence', t => { const tokens = tokenizeAnsi('A\u009B31mB'); const csiToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'csi', 'text'], ); if (csiToken?.type !== 'csi') { t.fail(); return; } t.is(csiToken.value, '\u009B31m'); t.is(csiToken.parameterString, '31'); t.is(csiToken.intermediateString, ''); t.is(csiToken.finalCharacter, 'm'); }); test('tokenize incomplete C1 CSI as invalid and stop', t => { const tokens = tokenizeAnsi('A\u009B31'); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'invalid', value: '\u009B31'}, ]); }); test('tokenize incomplete C1 OSC as invalid and stop', t => { const tokens = tokenizeAnsi('A\u009D8;;https://example.com'); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'invalid', value: '\u009D8;;https://example.com'}, ]); }); test('tokenize DCS with BEL in payload until ST terminator', t => { const tokens = tokenizeAnsi('A\u001BPpayload\u0007still-payload\u001B\\B'); const dcsToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'dcs', 'text'], ); if (dcsToken?.type !== 'dcs') { t.fail(); return; } t.true(dcsToken.value.includes('\u0007')); t.true(dcsToken.value.endsWith('\u001B\\')); }); test('tokenize C1 OSC control string with BEL terminator', t => { const tokens = tokenizeAnsi('A\u009D8;;https://example.com\u0007B'); const oscToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'osc', 'text'], ); if (oscToken?.type !== 'osc') { t.fail(); return; } t.is(oscToken.value, '\u009D8;;https://example.com\u0007'); }); test('tokenize ESC SOS control string with ST terminator', t => { const tokens = tokenizeAnsi('A\u001BXpayload\u001B\\B'); const sosToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'sos', 'text'], ); if (sosToken?.type !== 'sos') { t.fail(); return; } t.is(sosToken.value, '\u001BXpayload\u001B\\'); }); test('tokenize ESC SOS control string with C1 ST terminator', t => { const tokens = tokenizeAnsi('A\u001BXpayload\u009CB'); const sosToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'sos', 'text'], ); if (sosToken?.type !== 'sos') { t.fail(); return; } t.is(sosToken.value, '\u001BXpayload\u009C'); }); test('tokenize C1 SOS control string with C1 ST terminator', t => { const tokens = tokenizeAnsi('A\u0098payload\u009CB'); const sosToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'sos', 'text'], ); if (sosToken?.type !== 'sos') { t.fail(); return; } t.is(sosToken.value, '\u0098payload\u009C'); }); test('tokenize C1 SOS control string with ESC ST terminator', t => { const tokens = tokenizeAnsi('A\u0098payload\u001B\\B'); const sosToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'sos', 'text'], ); if (sosToken?.type !== 'sos') { t.fail(); return; } t.is(sosToken.value, '\u0098payload\u001B\\'); }); test('tokenize ESC SOS with BEL terminator as invalid and stop', t => { const tokens = tokenizeAnsi('A\u001BXpayload\u0007B'); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'invalid', value: '\u001BXpayload\u0007B'}, ]); }); test('tokenize C1 SOS with BEL terminator as invalid and stop', t => { const tokens = tokenizeAnsi('A\u0098payload\u0007B'); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'invalid', value: '\u0098payload\u0007B'}, ]); }); test('tokenize incomplete C1 SOS as invalid and stop', t => { const tokens = tokenizeAnsi('A\u0098payload'); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'invalid', value: '\u0098payload'}, ]); }); test('tokenize incomplete ESC SOS as invalid and stop', t => { const tokens = tokenizeAnsi('A\u001BXpayload'); t.deepEqual(tokens, [ {type: 'text', value: 'A'}, {type: 'invalid', value: '\u001BXpayload'}, ]); }); test('tokenize SOS with escaped ESC in payload until final ST terminator', t => { const tokens = tokenizeAnsi('A\u001BXfoo\u001B\u001B\\bar\u001B\\B'); const sosToken = tokens[1]; t.deepEqual( tokens.map(token => token.type), ['text', 'sos', 'text'], ); if (sosToken?.type !== 'sos') { t.fail(); return; } t.true(sosToken.value.includes('\u001B\u001B\\')); t.true(sosToken.value.endsWith('\u001B\\')); }); test('tokenize standalone C1 controls as c1 tokens', t => { const tokens = tokenizeAnsi('A\u0085B\u008EC'); const c1Token1 = tokens[1]; const c1Token2 = tokens[3]; t.deepEqual( tokens.map(token => token.type), ['text', 'c1', 'text', 'c1', 'text'], ); if (c1Token1?.type !== 'c1') { t.fail(); return; } if (c1Token2?.type !== 'c1') { t.fail(); return; } t.is(c1Token1.value, '\u0085'); t.is(c1Token2.value, '\u008E'); }); ================================================ FILE: test/background.tsx ================================================ import React from 'react'; import test from 'ava'; import chalk from 'chalk'; import {render, Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; import createStdout from './helpers/create-stdout.js'; import {renderAsync} from './helpers/test-renderer.js'; import {enableTestColors, disableTestColors} from './helpers/force-colors.js'; // ANSI escape sequences for background colors // Note: We test against raw ANSI codes rather than chalk predicates because: // 1. Different color reset patterns: // - Chalk: '\u001b[43mHello \u001b[49m\u001b[43mWorld\u001b[49m' (individual resets) // - Ink: '\u001b[43mHello World\u001b[49m' (continuous blocks) // 2. Background space fills that chalk doesn't generate: // - Ink: '\u001b[41mHello \u001b[49m\n\u001b[41m \u001b[49m' (fills entire Box area) // 3. Context-aware color transitions: // - Chalk: '\u001b[43mOuter: \u001b[49m\u001b[44mInner: \u001b[49m\u001b[41mExplicit\u001b[49m' // - Ink: '\u001b[43mOuter: \u001b[44mInner: \u001b[41mExplicit\u001b[49m' (no intermediate resets) const ansi = { // Standard colors bgRed: '\u001B[41m', bgGreen: '\u001B[42m', bgYellow: '\u001B[43m', bgBlue: '\u001B[44m', bgMagenta: '\u001B[45m', bgCyan: '\u001B[46m', // Hex/RGB colors (24-bit) bgHexRed: '\u001B[48;2;255;0;0m', // #FF0000 or rgb(255,0,0) // ANSI256 colors bgAnsi256Nine: '\u001B[48;5;9m', // Ansi256(9) // Reset bgReset: '\u001B[49m', } as const; // Enable colors for all tests test.before(() => { enableTestColors(); }); test.after(() => { disableTestColors(); }); // Text inheritance tests (these work in non-TTY) test('Text inherits parent Box background color', t => { const output = renderToString( Hello World , ); t.is(output, chalk.bgGreen('Hello World')); }); test('Text explicit background color overrides inherited', t => { const output = renderToString( Hello World , ); t.is(output, chalk.bgBlue('Hello World')); }); test('Nested Box background inheritance', t => { const output = renderToString( Hello World , ); t.is(output, chalk.bgBlue('Hello World')); }); test('Text without parent Box background has no inheritance', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello World'); }); test('Multiple Text elements inherit same background', t => { const output = renderToString( Hello World , ); // Text nodes are rendered as a single block with shared background t.is(output, chalk.bgYellow('Hello World')); }); test('Mixed text with and without background inheritance', t => { const output = renderToString( Inherited No BG Red BG , ); t.is(output, chalk.bgGreen('Inherited ') + 'No BG ' + chalk.bgRed('Red BG')); }); test('Complex nested structure with background inheritance', t => { const output = renderToString( Outer: Inner: Explicit , ); // Colors transition without reset codes between them - actual behavior from debug output t.is( output, `${ansi.bgYellow}Outer: ${ansi.bgBlue}Inner: ${ansi.bgRed}Explicit${ansi.bgReset}`, ); }); // Background color tests for different formats test('Box background with standard color', t => { const output = renderToString( Hello , ); t.is(output, chalk.bgRed('Hello')); }); test('Box background with hex color', t => { const output = renderToString( Hello , ); t.is(output, chalk.bgHex('#FF0000')('Hello')); }); test('Box background with rgb color', t => { const output = renderToString( Hello , ); t.is(output, chalk.bgRgb(255, 0, 0)('Hello')); }); test('Box background with ansi256 color', t => { const output = renderToString( Hello , ); t.is(output, chalk.bgAnsi256(9)('Hello')); }); test('Box background with wide characters', t => { const output = renderToString( こんにちは , ); t.is(output, chalk.bgYellow('こんにちは')); }); test('Box background with emojis', t => { const output = renderToString( 🎉🎊 , ); t.is(output, chalk.bgRed('🎉🎊')); }); // Box background space fill tests - these should work with forced colors test('Box background fills entire area with standard color', t => { const output = renderToString( Hello , ); // Should contain background color codes and fill spaces for entire Box area t.true( output.includes(ansi.bgRed), 'Should contain red background start code', ); t.true(output.includes(ansi.bgReset), 'Should contain background reset code'); t.true(output.includes('Hello'), 'Should contain the text'); t.true( output.includes(`${ansi.bgRed} ${ansi.bgReset}`), 'Should contain background fill line', ); }); test('Box background fills with hex color', t => { const output = renderToString( Hello , ); // Should contain hex color background codes and fill spaces t.true(output.includes('Hello'), 'Should contain the text'); t.true( output.includes(ansi.bgHexRed), 'Should contain hex RGB background code', ); t.true(output.includes(ansi.bgReset), 'Should contain background reset code'); }); test('Box background fills with rgb color', t => { const output = renderToString( Hello , ); // Should contain RGB color background codes and fill spaces t.true(output.includes('Hello'), 'Should contain the text'); t.true(output.includes(ansi.bgHexRed), 'Should contain RGB background code'); t.true(output.includes(ansi.bgReset), 'Should contain background reset code'); }); test('Box background fills with ansi256 color', t => { const output = renderToString( Hello , ); // Should contain ANSI256 color background codes and fill spaces t.true(output.includes('Hello'), 'Should contain the text'); t.true( output.includes(ansi.bgAnsi256Nine), 'Should contain ANSI256 background code', ); t.true(output.includes(ansi.bgReset), 'Should contain background reset code'); }); test('Box background with border fills content area', t => { const output = renderToString( Hi , ); // Should have background fill inside the border and border characters t.true(output.includes('Hi'), 'Should contain the text'); t.true(output.includes(ansi.bgCyan), 'Should contain cyan background code'); t.true(output.includes(ansi.bgReset), 'Should contain background reset code'); t.true(output.includes('╭'), 'Should contain top-left border'); t.true(output.includes('╮'), 'Should contain top-right border'); }); test('Box background with padding fills entire padded area', t => { const output = renderToString( Hi , ); // Background should fill the entire Box area including padding t.true(output.includes('Hi'), 'Should contain the text'); t.true( output.includes(ansi.bgMagenta), 'Should contain magenta background code', ); t.true(output.includes(ansi.bgReset), 'Should contain background reset code'); }); test('Box background with center alignment fills entire area', t => { const output = renderToString( Hi , ); t.true(output.includes('Hi'), 'Should contain centered text'); t.true(output.includes(ansi.bgBlue), 'Should contain blue background code'); t.true(output.includes(ansi.bgReset), 'Should contain background reset code'); }); test('Box background with column layout fills entire area', t => { const output = renderToString( Line 1 Line 2 , ); t.true(output.includes('Line 1'), 'Should contain first line text'); t.true(output.includes('Line 2'), 'Should contain second line text'); t.true(output.includes(ansi.bgGreen), 'Should contain green background code'); t.true(output.includes(ansi.bgReset), 'Should contain background reset code'); }); // Update tests using render() for comprehensive coverage test('Box background updates on rerender', t => { const stdout = createStdout(); function Test({bgColor}: {readonly bgColor?: string}) { return ( Hello ); } const {rerender} = render(, { stdout, debug: true, }); t.is((stdout.write as any).lastCall.args[0], 'Hello'); rerender(); t.is((stdout.write as any).lastCall.args[0], chalk.bgGreen('Hello')); rerender(); t.is((stdout.write as any).lastCall.args[0], 'Hello'); }); // Concurrent mode tests test('Text inherits parent Box background color - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); t.is(output, chalk.bgGreen('Hello World')); }); test('Nested Box background inheritance - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); t.is(output, chalk.bgBlue('Hello World')); }); test('Box background with hex color - concurrent', async t => { const output = await renderToStringAsync( Hello , ); t.is(output, chalk.bgHex('#FF0000')('Hello')); }); test('Box background updates on rerender - concurrent', async t => { function Test({bgColor}: {readonly bgColor?: string}) { return ( Hello ); } const {getOutput, rerenderAsync} = await renderAsync(); t.is(getOutput(), 'Hello'); await rerenderAsync(); t.is(getOutput(), chalk.bgGreen('Hello')); await rerenderAsync(); t.is(getOutput(), 'Hello'); }); test('Box backgroundColor fills full width on every line when text wraps', t => { // "Hello World!!" is 13 chars, width=10 forces wrapping into 2 lines const output = renderToString( Hello World!! , ); // Both lines are padded to the full 10-char Box width with background color t.is( output, `${ansi.bgRed}Hello ${ansi.bgReset}\n${ansi.bgRed}World!! ${ansi.bgReset}`, ); }); test('Text-only backgroundColor colors text content but does not fill Box width', t => { // Without a Box backgroundColor, only the text characters are colored const output = renderToString( Hello World!! , ); // Text-only bg colors just the text, not the remaining space to fill Box width t.is( output, `${ansi.bgRed}Hello ${ansi.bgReset}\n${ansi.bgRed}World!!${ansi.bgReset}`, ); }); ================================================ FILE: test/borders.tsx ================================================ import React from 'react'; import test from 'ava'; import boxen from 'boxen'; import indentString from 'indent-string'; import cliBoxes from 'cli-boxes'; import chalk from 'chalk'; import {render, Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; import createStdout from './helpers/create-stdout.js'; import {renderAsync} from './helpers/test-renderer.js'; test('single node - full width box', t => { const output = renderToString( Hello World , ); t.is(output, boxen('Hello World', {width: 100, borderStyle: 'round'})); }); test('single node - full width box with colorful border', t => { const output = renderToString( Hello World , ); t.is( output, boxen('Hello World', { width: 100, borderStyle: 'round', borderColor: 'green', }), ); }); test('single node - fit-content box', t => { const output = renderToString( Hello World , ); t.is(output, boxen('Hello World', {borderStyle: 'round'})); }); test('single node - fit-content box with wide characters', t => { const output = renderToString( こんにちは , ); t.is(output, boxen('こんにちは', {borderStyle: 'round'})); }); test('single node - fit-content box with emojis', t => { const output = renderToString( 🌊🌊 , ); t.is(output, boxen('🌊🌊', {borderStyle: 'round'})); }); // Issue #733: Emojis with variation selectors (FE0F) should align properly test('single node - fit-content box with variation selector emojis', t => { const output = renderToString( 🌡️⚠️✅ , ); t.is(output, boxen('🌡️⚠️✅', {borderStyle: 'round'})); }); test('single node - fixed width box', t => { const output = renderToString( Hello World , ); t.is(output, boxen('Hello World'.padEnd(18, ' '), {borderStyle: 'round'})); }); test('single node - fixed width and height box', t => { const output = renderToString( Hello World , ); t.is( output, boxen('Hello World'.padEnd(18, ' ') + '\n'.repeat(17), { borderStyle: 'round', }), ); }); test('single node - box with padding', t => { const output = renderToString( Hello World , ); t.is(output, boxen('\n Hello World \n', {borderStyle: 'round'})); }); test('single node - box with horizontal alignment', t => { const output = renderToString( Hello World , ); t.is(output, boxen(' Hello World ', {borderStyle: 'round'})); }); test('single node - box with vertical alignment', t => { const output = renderToString( Hello World , ); t.is( output, boxen('\n'.repeat(8) + 'Hello World' + '\n'.repeat(9), { borderStyle: 'round', }), ); }); test('single node - box with wrapping', t => { const output = renderToString( Hello World , ); t.is(output, boxen('Hello \nWorld', {borderStyle: 'round'})); }); test('multiple nodes - full width box', t => { const output = renderToString( {'Hello '}World , ); t.is(output, boxen('Hello World', {width: 100, borderStyle: 'round'})); }); test('multiple nodes - full width box with colorful border', t => { const output = renderToString( {'Hello '}World , ); t.is( output, boxen('Hello World', { width: 100, borderStyle: 'round', borderColor: 'green', }), ); }); test('multiple nodes - fit-content box', t => { const output = renderToString( {'Hello '}World , ); t.is(output, boxen('Hello World', {borderStyle: 'round'})); }); test('multiple nodes - fixed width box', t => { const output = renderToString( {'Hello '}World , ); t.is(output, boxen('Hello World'.padEnd(18, ' '), {borderStyle: 'round'})); }); test('multiple nodes - fixed width and height box', t => { const output = renderToString( {'Hello '}World , ); t.is( output, boxen('Hello World'.padEnd(18, ' ') + '\n'.repeat(17), { borderStyle: 'round', }), ); }); test('multiple nodes - box with padding', t => { const output = renderToString( {'Hello '}World , ); t.is(output, boxen('\n Hello World \n', {borderStyle: 'round'})); }); test('multiple nodes - box with horizontal alignment', t => { const output = renderToString( {'Hello '}World , ); t.is(output, boxen(' Hello World ', {borderStyle: 'round'})); }); test('multiple nodes - box with vertical alignment', t => { const output = renderToString( {'Hello '}World , ); t.is( output, boxen('\n'.repeat(8) + 'Hello World' + '\n'.repeat(9), { borderStyle: 'round', }), ); }); test('multiple nodes - box with wrapping', t => { const output = renderToString( {'Hello '}World , ); t.is(output, boxen('Hello \nWorld', {borderStyle: 'round'})); }); test('multiple nodes - box with wrapping and long first node', t => { const output = renderToString( {'Helloooooo'} World , ); t.is(output, boxen('Helloooo\noo World', {borderStyle: 'round'})); }); test('multiple nodes - box with wrapping and very long first node', t => { const output = renderToString( {'Hellooooooooooooo'} World , ); t.is(output, boxen('Helloooo\noooooooo\no World', {borderStyle: 'round'})); }); test('nested boxes', t => { const output = renderToString( Hello World , ); const nestedBox = indentString( boxen('\n Hello World \n', {borderStyle: 'round'}), 1, ); t.is( output, boxen(`${' '.repeat(38)}\n${nestedBox}\n`, {borderStyle: 'round'}), ); }); test('nested boxes - fit-content box with wide characters on flex-direction row', t => { const output = renderToString( ミスター スポック カーク船長 , ); const box1 = boxen('ミスター', {borderStyle: 'round'}); const box2 = boxen('スポック', {borderStyle: 'round'}); const box3 = boxen('カーク船長', {borderStyle: 'round'}); const expected = boxen( box1 .split('\n') .map( (line, index) => line + box2.split('\n')[index]! + box3.split('\n')[index]!, ) .join('\n'), {borderStyle: 'round'}, ); t.is(output, expected); }); test('nested boxes - fit-content box with emojis on flex-direction row', t => { const output = renderToString( 🦾 🌏 😋 , ); const box1 = boxen('🦾', {borderStyle: 'round'}); const box2 = boxen('🌏', {borderStyle: 'round'}); const box3 = boxen('😋', {borderStyle: 'round'}); const expected = boxen( box1 .split('\n') .map( (line, index) => line + box2.split('\n')[index]! + box3.split('\n')[index]!, ) .join('\n'), {borderStyle: 'round'}, ); t.is(output, expected); }); test('nested boxes - fit-content box with wide characters on flex-direction column', t => { const output = renderToString( ミスター スポック カーク船長 , ); const expected = boxen( boxen('ミスター ', {borderStyle: 'round'}) + '\n' + boxen('スポック ', {borderStyle: 'round'}) + '\n' + boxen('カーク船長', {borderStyle: 'round'}), {borderStyle: 'round'}, ); t.is(output, expected); }); test('nested boxes - fit-content box with emojis on flex-direction column', t => { const output = renderToString( 🦾 🌏 😋 , ); const expected = boxen( boxen('🦾', {borderStyle: 'round'}) + '\n' + boxen('🌏', {borderStyle: 'round'}) + '\n' + boxen('😋', {borderStyle: 'round'}), {borderStyle: 'round'}, ); t.is(output, expected); }); test('render border after update', t => { const stdout = createStdout(); function Test({borderColor}: {readonly borderColor?: string}) { return ( Hello World ); } const {rerender} = render(, { stdout, debug: true, }); t.is( (stdout.write as any).lastCall.args[0], boxen('Hello World', {width: 100, borderStyle: 'round'}), ); rerender(); t.is( (stdout.write as any).lastCall.args[0], boxen('Hello World', { width: 100, borderStyle: 'round', borderColor: 'green', }), ); rerender(); t.is( (stdout.write as any).lastCall.args[0], boxen('Hello World', { width: 100, borderStyle: 'round', }), ); }); test('render border edge changes after update when borderStyle is unchanged', t => { const stdout = createStdout(); function Test({borderTop}: {readonly borderTop?: boolean}) { return ( Content ); } const {rerender} = render(, { stdout, debug: true, }); t.is( (stdout.write as any).lastCall.args[0], boxen('Content', {borderStyle: 'round'}), ); rerender(); t.is( (stdout.write as any).lastCall.args[0], [ `${cliBoxes.round.left}Content${cliBoxes.round.right}`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, ].join('\n'), ); rerender(); t.is( (stdout.write as any).lastCall.args[0], boxen('Content', {borderStyle: 'round'}), ); }); test('hide top border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.left}Content${cliBoxes.round.right}`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, 'Below', ].join('\n'), ); }); test('hide bottom border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, `${cliBoxes.round.left}Content${cliBoxes.round.right}`, 'Below', ].join('\n'), ); }); test('hide top and bottom borders', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.left}Content${cliBoxes.round.right}`, 'Below', ].join('\n'), ); }); test('hide left border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.top.repeat(7)}${cliBoxes.round.topRight}`, `Content${cliBoxes.round.right}`, `${cliBoxes.round.bottom.repeat(7)}${cliBoxes.round.bottomRight}`, 'Below', ].join('\n'), ); }); test('hide right border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}`, `${cliBoxes.round.left}Content`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}`, 'Below', ].join('\n'), ); }); test('hide left and right border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', cliBoxes.round.top.repeat(7), 'Content', cliBoxes.round.bottom.repeat(7), 'Below', ].join('\n'), ); }); test('hide all borders', t => { const output = renderToString( Above Content Below , ); t.is(output, ['Above', 'Content', 'Below'].join('\n')); }); test('change color of top border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', chalk.green( `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, ), `${cliBoxes.round.left}Content${cliBoxes.round.right}`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, 'Below', ].join('\n'), ); }); test('change color of bottom border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, `${cliBoxes.round.left}Content${cliBoxes.round.right}`, chalk.green( `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, ), 'Below', ].join('\n'), ); }); test('change color of left border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, `${chalk.green(cliBoxes.round.left)}Content${cliBoxes.round.right}`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, 'Below', ].join('\n'), ); }); test('change color of right border', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, `${cliBoxes.round.left}Content${chalk.green(cliBoxes.round.right)}`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, 'Below', ].join('\n'), ); }); test('custom border style', t => { const output = renderToString( Content , ); t.is(output, boxen('Content', {width: 100, borderStyle: 'arrow'})); }); test('dim border color', t => { const output = renderToString( Content , ); t.is( output, boxen('Content', { width: 100, borderStyle: 'round', dimBorder: true, }), ); }); test('dim top border color', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', chalk.dim( `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, ), `${cliBoxes.round.left}Content${cliBoxes.round.right}`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, 'Below', ].join('\n'), ); }); test('dim bottom border color', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, `${cliBoxes.round.left}Content${cliBoxes.round.right}`, chalk.dim( `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, ), 'Below', ].join('\n'), ); }); test('dim left border color', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, `${chalk.dim(cliBoxes.round.left)}Content${cliBoxes.round.right}`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, 'Below', ].join('\n'), ); }); test('dim right border color', t => { const output = renderToString( Above Content Below , ); t.is( output, [ 'Above', `${cliBoxes.round.topLeft}${cliBoxes.round.top.repeat(7)}${ cliBoxes.round.topRight }`, `${cliBoxes.round.left}Content${chalk.dim(cliBoxes.round.right)}`, `${cliBoxes.round.bottomLeft}${cliBoxes.round.bottom.repeat(7)}${ cliBoxes.round.bottomRight }`, 'Below', ].join('\n'), ); }); // Regression test for https://github.com/vadimdemedes/ink/issues/840 // borderDimColor should not dim styled child Text components touching the left edge test('borderDimColor does not dim styled child Text touching left edge', t => { const output = renderToString( styled text , ); // The styled text should be bold and blue (not dimmed) // Note: Text component applies color first then bold, so the escape code order is bold+blue const styledText = chalk.bold(chalk.blue('styled text')); t.true( output.includes(styledText), 'Child text should retain its color and bold styling, not be dimmed', ); // The border should be dimmed (entire top border line is dimmed as a unit) const dimmedTopBorder = chalk.dim( cliBoxes.round.topLeft + cliBoxes.round.top.repeat(11) + cliBoxes.round.topRight, ); t.true(output.includes(dimmedTopBorder), 'Border should be dimmed'); }); // Concurrent mode tests test('single node - full width box - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); t.is(output, boxen('Hello World', {width: 100, borderStyle: 'round'})); }); test('single node - fit-content box - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); t.is(output, boxen('Hello World', {borderStyle: 'round'})); }); test('nested boxes - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); const nestedBox = indentString( boxen('\n Hello World \n', {borderStyle: 'round'}), 1, ); t.is( output, boxen(`${' '.repeat(38)}\n${nestedBox}\n`, {borderStyle: 'round'}), ); }); test('render border after update - concurrent', async t => { function Test({borderColor}: {readonly borderColor?: string}) { return ( Hello World ); } const {getOutput, rerenderAsync} = await renderAsync(); t.is(getOutput(), boxen('Hello World', {width: 100, borderStyle: 'round'})); await rerenderAsync(); t.is( getOutput(), boxen('Hello World', { width: 100, borderStyle: 'round', borderColor: 'green', }), ); }); ================================================ FILE: test/components.tsx ================================================ import EventEmitter from 'node:events'; import process from 'node:process'; import test from 'ava'; import chalk from 'chalk'; import stripAnsi from 'strip-ansi'; import React, {Component, useEffect, useState} from 'react'; import {spy, stub} from 'sinon'; import ansiEscapes from 'ansi-escapes'; import { Box, Newline, render, Spacer, Static, Text, Transform, useApp, useInput, useStdin, } from '../src/index.js'; import createStdout from './helpers/create-stdout.js'; import {emitReadable} from './helpers/create-stdin.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; import {run} from './helpers/run.js'; import {renderAsync} from './helpers/test-renderer.js'; test('text', t => { const output = renderToString(Hello World); t.is(output, 'Hello World'); }); test('text with variable', t => { const output = renderToString(Count: {1}); t.is(output, 'Count: 1'); }); test('multiple text nodes', t => { const output = renderToString( {'Hello'} {' World'} , ); t.is(output, 'Hello World'); }); test('text with component', t => { function World() { return World; } const output = renderToString( Hello , ); t.is(output, 'Hello World'); }); test('text with fragment', t => { const output = renderToString( Hello <>World {/* eslint-disable-line react/jsx-no-useless-fragment */} , ); t.is(output, 'Hello World'); }); test('wrap text', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello\nWorld'); }); test('don’t wrap text if there is enough space', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello World'); }); test('truncate text in the end', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello …'); }); test('truncate text in the middle', t => { const output = renderToString( Hello World , ); t.is(output, 'Hel…rld'); }); test('truncate text in the beginning', t => { const output = renderToString( Hello World , ); t.is(output, '… World'); }); // See https://github.com/vadimdemedes/ink/issues/633 test('do not wrap text with BEL-terminated OSC hyperlinks', t => { // "Click here" is 10 chars, box is 20 wide - should not wrap const hyperlink = '\u001B]8;;https://example.com\u0007Click here\u001B]8;;\u0007'; const output = renderToString( {hyperlink} , ); t.is(stripAnsi(output), 'Click here'); }); // See https://github.com/vadimdemedes/ink/issues/633 test('do not wrap text with ST-terminated OSC hyperlinks', t => { const hyperlink = '\u001B]8;;https://example.com\u001B\\Click here\u001B]8;;\u001B\\'; const output = renderToString( {hyperlink} , ); t.is(stripAnsi(output), 'Click here'); }); // See https://github.com/vadimdemedes/ink/issues/633 test('do not wrap text with non-hyperlink OSC sequences', t => { // Title-setting OSC followed by visible text const text = '\u001B]0;My Title\u0007Some text'; const output = renderToString( {text} , ); t.is(stripAnsi(output), 'Some text'); }); // See https://github.com/vadimdemedes/ink/issues/633 test('hard-wrap single-word BEL-terminated OSC hyperlink', t => { // "abcdefghij" is 10 chars, box is 5 wide - forces wrapWord codepath const hyperlink = '\u001B]8;;https://example.com\u0007abcdefghij\u001B]8;;\u0007'; const output = renderToString( {hyperlink} , ); t.is(stripAnsi(output), 'abcde\nfghij'); }); // See https://github.com/vadimdemedes/ink/issues/633 test('hard-wrap single-word ST-terminated OSC hyperlink', t => { const hyperlink = '\u001B]8;;https://example.com\u001B\\abcdefghij\u001B]8;;\u001B\\'; const output = renderToString( {hyperlink} , ); t.is(stripAnsi(output), 'abcde\nfghij'); }); test('ignore empty text node', t => { const output = renderToString( Hello World {''} , ); t.is(output, 'Hello World'); }); test('render a single empty text node', t => { const output = renderToString({''}); t.is(output, ''); }); test('number', t => { const output = renderToString({1}); t.is(output, '1'); }); test('fail when text nodes are not within component', t => { let error: Error | undefined; class ErrorBoundary extends Component<{children?: React.ReactNode}> { override render(): React.ReactNode { return this.props.children; } override componentDidCatch(reactError: Error): void { error = reactError; } } renderToString( Hello World , ); t.truthy(error); t.is( error?.message, 'Text string "Hello" must be rendered inside component', ); }); test('fail when text node is not within component', t => { let error: Error | undefined; class ErrorBoundary extends Component<{children?: React.ReactNode}> { override render(): React.ReactNode { return this.props.children; } override componentDidCatch(reactError: Error): void { error = reactError; } } renderToString( Hello World , ); t.truthy(error); t.is( error?.message, 'Text string "Hello World" must be rendered inside component', ); }); test('fail when is inside component', t => { let error: Error | undefined; class ErrorBoundary extends Component<{children?: React.ReactNode}> { override render(): React.ReactNode { return this.props.children; } override componentDidCatch(reactError: Error): void { error = reactError; } } renderToString( Hello World , ); t.truthy(error); t.is((error as any).message, ' can’t be nested inside component'); }); test('remeasure text dimensions on text change', t => { const stdout = createStdout(); const {rerender} = render( Hello , {stdout, debug: true}, ); t.is((stdout.write as any).lastCall.args[0], 'Hello'); rerender( Hello World , ); t.is((stdout.write as any).lastCall.args[0], 'Hello World'); }); test('fragment', t => { const output = renderToString( // eslint-disable-next-line react/jsx-no-useless-fragment <> Hello World , ); t.is(output, 'Hello World'); }); test('transform children', t => { const output = renderToString( `[${index}: ${string}]`} > `{${index}: ${string}}`} > test , ); t.is(output, '[0: {0: test}]'); }); test('squash multiple text nodes', t => { const output = renderToString( `[${index}: ${string}]`} > `{${index}: ${string}}`} > {/* prettier-ignore */} hello{' '}world , ); t.is(output, '[0: {0: hello world}]'); }); test('transform with multiple lines', t => { const output = renderToString( `[${index}: ${string}]`} > {/* prettier-ignore */} hello{' '}world{'\n'}goodbye{' '}world , ); t.is(output, '[0: hello world]\n[1: goodbye world]'); }); test('squash multiple nested text nodes', t => { const output = renderToString( `[${index}: ${string}]`} > `{${index}: ${string}}`} > hello world , ); t.is(output, '[0: {0: hello world}]'); }); test('squash empty `` nodes', t => { const output = renderToString( `[${string}]`}> `{${string}}`}> {[]} , ); t.is(output, ''); }); test(' with undefined children', t => { const output = renderToString( children} />); t.is(output, ''); }); test(' with null children', t => { const output = renderToString( children} />); t.is(output, ''); }); test('hooks', t => { function WithHooks() { const [value, setValue] = useState('Hello'); return {value}; } const output = renderToString(); t.is(output, 'Hello'); }); test('static output', t => { const output = renderToString( {letter => {letter}} X , ); t.is(output, 'A\nB\nC\n\n\nX'); }); test('skip previous output when rendering new static output', t => { const stdout = createStdout(); function Dynamic({items}: {readonly items: string[]}) { return ( {item => {item}} ); } const {rerender} = render(, { stdout, debug: true, }); t.is((stdout.write as any).lastCall.args[0], 'A\n'); rerender(); t.is((stdout.write as any).lastCall.args[0], 'A\nB\n'); }); test('render only new items in static output on final render', t => { const stdout = createStdout(); function Dynamic({items}: {readonly items: string[]}) { return ( {item => {item}} ); } const {rerender, unmount} = render(, { stdout, debug: true, }); t.is((stdout.write as any).lastCall.args[0], ''); rerender(); t.is((stdout.write as any).lastCall.args[0], 'A\n'); rerender(); unmount(); // Filter out cursor management escapes (show/hide) to check content writes. // With isTTY=true, cli-cursor writes a show-cursor sequence on unmount. const allWrites = stdout.getWrites(); const lastContentWrite = allWrites.findLast(w => !w.startsWith('\u001B[?25')); t.is(lastContentWrite, 'A\nB\n'); }); // See https://github.com/chalk/wrap-ansi/issues/27 test('ensure wrap-ansi doesn’t trim leading whitespace', t => { const output = renderToString({' ERROR '}); t.is(output, chalk.red(' ERROR ')); }); test('replace child node with text', t => { const stdout = createStdout(); function Dynamic({replace}: {readonly replace?: boolean}) { return {replace ? 'x' : test}; } const {rerender} = render(, { stdout, debug: true, }); t.is((stdout.write as any).lastCall.args[0], chalk.green('test')); rerender(); t.is((stdout.write as any).lastCall.args[0], 'x'); }); // See https://github.com/vadimdemedes/ink/issues/145 test('disable raw mode when all input components are unmounted', t => { const stdout = createStdout(); const stdin = new EventEmitter() as NodeJS.WriteStream; stdin.setEncoding = () => {}; stdin.setRawMode = spy(); stdin.isTTY = true; // Without this, setRawMode will throw stdin.ref = spy(); stdin.unref = spy(); const options = { stdout, stdin, debug: true, }; class Input extends React.Component<{setRawMode: (mode: boolean) => void}> { override render() { return Test; } override componentDidMount() { this.props.setRawMode(true); } override componentWillUnmount() { this.props.setRawMode(false); } } function Test({ renderFirstInput, renderSecondInput, }: { readonly renderFirstInput?: boolean; readonly renderSecondInput?: boolean; }) { const {setRawMode} = useStdin(); return ( <> {renderFirstInput ? : null} {renderSecondInput ? : null} ); } const {rerender} = render( , // eslint-disable-next-line @typescript-eslint/no-unsafe-argument options as any, ); t.true(stdin.setRawMode.calledOnce); t.true(stdin.ref.calledOnce); t.deepEqual(stdin.setRawMode.firstCall.args, [true]); rerender(); t.true(stdin.setRawMode.calledOnce); t.true(stdin.ref.calledOnce); t.true(stdin.unref.notCalled); rerender(); t.true(stdin.setRawMode.calledTwice); t.true(stdin.ref.calledOnce); t.true(stdin.unref.calledOnce); t.deepEqual(stdin.setRawMode.lastCall.args, [false]); }); test('re-ref stdin when input is used after previous unmount', t => { const stdin = new EventEmitter() as NodeJS.WriteStream; stdin.setEncoding = () => {}; stdin.read = stub(); stdin.setRawMode = spy(); stdin.isTTY = true; // Without this, setRawMode will throw stdin.ref = spy(); stdin.unref = spy(); const options = { stdout: createStdout(), stdin, debug: true, }; class Input extends React.Component<{setRawMode: (mode: boolean) => void}> { override render() { return Test; } override componentDidMount() { this.props.setRawMode(true); } override componentWillUnmount() { this.props.setRawMode(false); } } function Test({onInput}: {readonly onInput: (input: string) => void}) { const {setRawMode} = useStdin(); useInput(input => { onInput(input); }); return ; } const onFirstMountInput = spy(); const onSecondMountInput = spy(); // First render const {unmount} = render( , // eslint-disable-next-line @typescript-eslint/no-unsafe-argument options as any, ); t.true(stdin.ref.calledOnce); t.true(stdin.setRawMode.calledOnce); t.deepEqual(stdin.setRawMode.firstCall.args, [true]); emitReadable(stdin, 'a'); t.is(onFirstMountInput.callCount, 1); t.deepEqual(onFirstMountInput.firstCall.args, ['a']); // Unmount first instance unmount(); t.true(stdin.unref.calledOnce); t.true(stdin.setRawMode.calledTwice); t.deepEqual(stdin.setRawMode.lastCall.args, [false]); // Second render with new Ink instance reusing the same stdin const {unmount: unmount2} = render( , // eslint-disable-next-line @typescript-eslint/no-unsafe-argument options as any, ); t.true(stdin.ref.calledTwice); t.true(stdin.setRawMode.calledThrice); t.deepEqual(stdin.setRawMode.lastCall.args, [true]); emitReadable(stdin, 'b'); t.is(onSecondMountInput.callCount, 1); t.deepEqual(onSecondMountInput.firstCall.args, ['b']); t.is(onFirstMountInput.callCount, 1); // Unmount second instance unmount2(); t.true(stdin.unref.calledTwice); t.is(stdin.setRawMode.callCount, 4); t.deepEqual(stdin.setRawMode.lastCall.args, [false]); }); test('setRawMode() should throw if raw mode is not supported', t => { const stdout = createStdout(); const stdin = new EventEmitter() as NodeJS.ReadStream; stdin.setEncoding = () => {}; stdin.setRawMode = spy(); stdin.isTTY = false; const didCatchInMount = spy(); const didCatchInUnmount = spy(); const options = { stdout, stdin, debug: true, }; class Input extends React.Component<{setRawMode: (mode: boolean) => void}> { override render() { return Test; } override componentDidMount() { try { this.props.setRawMode(true); } catch (error: unknown) { didCatchInMount(error); } } override componentWillUnmount() { try { this.props.setRawMode(false); } catch (error: unknown) { didCatchInUnmount(error); } } } function Test() { const {setRawMode} = useStdin(); return ; } const {unmount} = render(, options); unmount(); t.is(didCatchInMount.callCount, 1); t.is(didCatchInUnmount.callCount, 1); t.false(stdin.setRawMode.called); }); test('render different component based on whether stdin is a TTY or not', t => { const stdout = createStdout(); const stdin = new EventEmitter() as NodeJS.WriteStream; stdin.setEncoding = () => {}; stdin.setRawMode = spy(); stdin.isTTY = false; const options = { stdout, stdin, debug: true, }; class Input extends React.Component<{setRawMode: (mode: boolean) => void}> { override render() { return Test; } override componentDidMount() { this.props.setRawMode(true); } override componentWillUnmount() { this.props.setRawMode(false); } } function Test({ renderFirstInput, renderSecondInput, }: { readonly renderFirstInput?: boolean; readonly renderSecondInput?: boolean; }) { const {isRawModeSupported, setRawMode} = useStdin(); return ( <> {isRawModeSupported && renderFirstInput ? ( ) : null} {isRawModeSupported && renderSecondInput ? ( ) : null} ); } const {rerender} = render( , // eslint-disable-next-line @typescript-eslint/no-unsafe-argument options as any, ); t.false(stdin.setRawMode.called); rerender(); t.false(stdin.setRawMode.called); rerender(); t.false(stdin.setRawMode.called); }); test('render only last frame when run in CI', async t => { const output = await run('ci', { // eslint-disable-next-line @typescript-eslint/naming-convention env: {CI: 'true'}, columns: 0, }); for (const num of [0, 1, 2, 3, 4]) { t.false(output.includes(`Counter: ${num}`)); } t.true(output.includes('Counter: 5')); }); test('render all frames if CI environment variable equals false', async t => { const output = await run('ci', { // eslint-disable-next-line @typescript-eslint/naming-convention env: {CI: 'false'}, columns: 0, }); for (const num of [0, 1, 2, 3, 4, 5]) { t.true(output.includes(`Counter: ${num}`)); } }); test('debug mode in CI does not replay final frame during unmount teardown', async t => { const output = await run('ci-debug', { // eslint-disable-next-line @typescript-eslint/naming-convention env: {CI: 'true'}, columns: 0, }); const plainOutput = stripAnsi(output).replaceAll('\r', ''); const helloCount = plainOutput.match(/Hello/g)?.length ?? 0; t.is(helloCount, 2); }); test('debug mode in CI keeps final newline separation after waitUntilExit', async t => { const output = await run('ci-debug-after-exit', { // eslint-disable-next-line @typescript-eslint/naming-convention env: {CI: 'true'}, columns: 0, }); const plainOutput = stripAnsi(output).replaceAll('\r', ''); t.is(plainOutput, 'HelloHello\nDONE'); }); test('render only last frame when stdout is not a TTY', async t => { const stdout = createStdout(100, false); function Counter() { const [count, setCount] = useState(0); React.useEffect(() => { if (count < 3) { const timer = setTimeout(() => { setCount(c => c + 1); }, 10); return () => { clearTimeout(timer); }; } }, [count]); return Count: {count}; } const {unmount, waitUntilExit} = render(, { stdout, debug: false, }); await new Promise(resolve => { setTimeout(resolve, 200); }); unmount(); await waitUntilExit(); const allWrites = stdout.getWrites(); // Verify no intermediate frames were written const contentWrites = allWrites.map(w => stripAnsi(w)); for (const intermediate of ['Count: 0', 'Count: 1', 'Count: 2']) { t.false( contentWrites.some(w => w.includes(intermediate)), `Intermediate frame "${intermediate}" should not be written in non-interactive mode`, ); } // Verify no erase/cursor ANSI sequences were emitted const hasEraseSequence = allWrites.some(w => w.includes(ansiEscapes.eraseLines(1)), ); t.false(hasEraseSequence); // Verify the final frame is written const lastWrite = allWrites.at(-1) ?? ''; t.true(lastWrite.includes('Count: 3')); }); test('render all frames when interactive is explicitly true', async t => { const stdout = createStdout(100, false); function Counter() { const [count, setCount] = useState(0); React.useEffect(() => { if (count < 2) { const timer = setTimeout(() => { setCount(c => c + 1); }, 50); return () => { clearTimeout(timer); }; } }, [count]); return Count: {count}; } const {unmount, waitUntilExit} = render(, { stdout, debug: false, interactive: true, }); await new Promise(resolve => { setTimeout(resolve, 500); }); unmount(); await waitUntilExit(); const contentWrites = stdout.getWrites().filter(w => w.length > 0); t.true(contentWrites.length > 1); const joined = contentWrites.join(''); t.true(joined.includes('Count: 0')); t.true(joined.includes('Count: 1')); t.true(joined.includes('Count: 2')); }); test('interactive option overrides TTY detection', async t => { const stdout = createStdout(100, true); function Counter() { const [count, setCount] = useState(0); React.useEffect(() => { if (count < 3) { const timer = setTimeout(() => { setCount(c => c + 1); }, 10); return () => { clearTimeout(timer); }; } }, [count]); return Count: {count}; } const {unmount, waitUntilExit} = render(, { stdout, debug: false, interactive: false, }); await new Promise(resolve => { setTimeout(resolve, 200); }); unmount(); await waitUntilExit(); const allWrites = stdout.getWrites(); // Verify no intermediate frames were written const contentWrites = allWrites.map(w => stripAnsi(w)); for (const intermediate of ['Count: 0', 'Count: 1', 'Count: 2']) { t.false( contentWrites.some(w => w.includes(intermediate)), `Intermediate frame "${intermediate}" should not be written when interactive=false overrides TTY`, ); } // Verify no erase/cursor ANSI sequences were emitted const hasEraseSequence = allWrites.some(w => w.includes(ansiEscapes.eraseLines(1)), ); t.false(hasEraseSequence); // Verify only the final frame is written const lastWrite = allWrites.at(-1) ?? ''; t.true(lastWrite.includes('Count: 3')); }); test('alternate screen - enters on mount and exits on unmount', async t => { const stdout = createStdout(100, true); const {unmount, waitUntilExit} = render(Hello, { stdout, alternateScreen: true, interactive: true, }); unmount(); await waitUntilExit(); const allWrites = stdout.getWrites(); const enterIndex = allWrites.findIndex(w => w.includes(ansiEscapes.enterAlternativeScreen), ); const exitIndex = allWrites.findLastIndex(w => w.includes(ansiEscapes.exitAlternativeScreen), ); t.not(enterIndex, -1, 'Should write enterAlternativeScreen on mount'); t.not(exitIndex, -1, 'Should write exitAlternativeScreen on unmount'); t.true( enterIndex < exitIndex, 'enterAlternativeScreen must come before exitAlternativeScreen', ); t.is(enterIndex, 0, 'enterAlternativeScreen should be the first write'); }); test.serial( 'primary screen - cleanup console output follows the native console during unmount', async t => { const stdout = createStdout(100, true); const processStdoutWriteStub = stub(process.stdout, 'write').callsFake((( _chunk: string | Uint8Array, encoding?: BufferEncoding | ((error?: Error) => void), callback?: (error?: Error) => void, ) => { if (typeof encoding === 'function') { encoding(); } if (typeof callback === 'function') { callback(); } return true; }) as typeof process.stdout.write); t.teardown(() => { processStdoutWriteStub.restore(); }); function Test() { useEffect(() => { return () => { console.log('primary cleanup'); }; }, []); return Hello; } const {unmount, waitUntilExit} = render(, { stdout, interactive: true, }); unmount(); await waitUntilExit(); const output = stdout.getWrites().join(''); const nativeConsoleLog = processStdoutWriteStub .getCalls() .some(call => String(call.args[0]).includes('primary cleanup')); t.false( output.includes('primary cleanup'), 'Should keep cleanup console output out of Ink-managed stdout writes', ); t.true( nativeConsoleLog, 'Should restore the native console before React cleanup runs', ); }, ); test.serial( 'alternate screen - does not replay exit(Error) output on the primary screen during unmount', async t => { const stdout = createStdout(100, true); function Test() { const {exit} = useApp(); useEffect(() => { exit(new Error('Done')); }, [exit]); return Done; } const {waitUntilExit} = render(, { stdout, alternateScreen: true, interactive: true, }); await t.throwsAsync(waitUntilExit()); const allWrites = stdout.getWrites(); const exitIndex = allWrites.findLastIndex(write => write.includes(ansiEscapes.exitAlternativeScreen), ); const replayedErrorOutput = allWrites.slice(exitIndex + 1).some(write => { const plainWrite = stripAnsi(write); return ( plainWrite.includes('Error: Done') || plainWrite.includes('Done\n at') ); }); t.not(exitIndex, -1, 'Should exit the alternate screen on unmount'); t.false( replayedErrorOutput, 'Should not replay alternate-screen diagnostics onto the primary screen', ); }, ); test.serial( 'alternate screen - does not replay teardown output on the primary screen during unmount', async t => { const stdout = createStdout(100, true); function Test() { const {exit} = useApp(); useEffect(() => { exit(new Error('Done')); }, [exit]); return normal ERROR banner; } const {waitUntilExit} = render(, { stdout, alternateScreen: true, interactive: true, }); await t.throwsAsync(waitUntilExit()); const allWrites = stdout.getWrites(); const exitIndex = allWrites.findLastIndex(write => write.includes(ansiEscapes.exitAlternativeScreen), ); const replayedOutput = stripAnsi(allWrites.slice(exitIndex + 1).join('')); t.not(exitIndex, -1, 'Should exit the alternate screen on unmount'); t.false( replayedOutput.includes('normal ERROR banner') || replayedOutput.includes('Error: Done') || replayedOutput.includes('Done\n at'), 'Should not replay alternate-screen teardown output onto the primary screen', ); }, ); test.serial( 'alternate screen - cleanup console output follows the native console during unmount', async t => { const stdout = createStdout(100, true); const processStdoutWriteStub = stub(process.stdout, 'write').callsFake((( _chunk: string | Uint8Array, encoding?: BufferEncoding | ((error?: Error) => void), callback?: (error?: Error) => void, ) => { if (typeof encoding === 'function') { encoding(); } if (typeof callback === 'function') { callback(); } return true; }) as typeof process.stdout.write); t.teardown(() => { processStdoutWriteStub.restore(); }); function Test() { useEffect(() => { return () => { console.log('cleanup log'); }; }, []); return Hello; } const {unmount, waitUntilExit} = render(, { stdout, alternateScreen: true, interactive: true, }); unmount(); await waitUntilExit(); const output = stdout.getWrites().join(''); const nativeConsoleLog = processStdoutWriteStub .getCalls() .some(call => String(call.args[0]).includes('cleanup log')); t.false( output.includes('cleanup log'), 'Should keep cleanup console output out of the alternate-screen stream', ); t.true( nativeConsoleLog, 'Should restore the native console before React cleanup runs', ); }, ); test.serial( 'alternate screen - cleanup() exits the alternate screen', async t => { const stdout = createStdout(100, true); const {cleanup, waitUntilExit} = render(Hello, { stdout, alternateScreen: true, interactive: true, }); cleanup(); await waitUntilExit(); const allWrites = stdout.getWrites(); const exitIndex = allWrites.findLastIndex(write => write.includes(ansiEscapes.exitAlternativeScreen), ); t.not(exitIndex, -1, 'Should exit the alternate screen during cleanup()'); }, ); test.serial( 'alternate screen - debug concurrent teardown restores the cursor before the first commit', async t => { const stdout = createStdout(100, true); const showCursorEscape = '\u001B[?25h'; const {unmount, waitUntilExit} = render(Hello, { stdout, alternateScreen: true, concurrent: true, debug: true, }); unmount(); await waitUntilExit(); const output = stdout.getWrites().join(''); const exitIndex = output.lastIndexOf(ansiEscapes.exitAlternativeScreen); const showCursorIndex = output.lastIndexOf(showCursorEscape); t.not(exitIndex, -1, 'Should exit the alternate screen on unmount'); t.true( showCursorIndex > exitIndex, 'Should restore the cursor after leaving the alternate screen', ); }, ); test('render warns when stdout is reused before unmount', async t => { const stdout = createStdout(100, true); const processStderrWriteStub = stub(process.stderr, 'write').callsFake((( _chunk: string | Uint8Array, encoding?: BufferEncoding | ((error?: Error) => void), callback?: (error?: Error) => void, ) => { if (typeof encoding === 'function') { encoding(); } if (typeof callback === 'function') { callback(); } return true; }) as typeof process.stderr.write); t.teardown(() => { processStderrWriteStub.restore(); }); render(Primary screen, { stdout, interactive: true, alternateScreen: true, patchConsole: false, }); const {unmount, waitUntilExit} = render(Second render, { stdout, }); t.true( processStderrWriteStub.calledOnceWithExactly( 'Warning: render() was called again for the same stdout before the previous Ink instance was unmounted. Reusing stdout across multiple render() calls is unsupported. Call unmount() first.\n', ), ); unmount(); await waitUntilExit(); }); test('alternate screen - ignored when non-interactive', async t => { const stdout = createStdout(100, true); const {unmount, waitUntilExit} = render(Hello, { stdout, alternateScreen: true, interactive: false, }); unmount(); await waitUntilExit(); const allWrites = stdout.getWrites(); t.false( allWrites.some(w => w.includes(ansiEscapes.enterAlternativeScreen)), 'Should not write enterAlternativeScreen in non-interactive mode', ); t.false( allWrites.some(w => w.includes(ansiEscapes.exitAlternativeScreen)), 'Should not write exitAlternativeScreen in non-interactive mode', ); }); test('alternate screen - disabled by default', async t => { const stdout = createStdout(100, true); const {unmount, waitUntilExit} = render(Hello, { stdout, interactive: true, }); unmount(); await waitUntilExit(); const allWrites = stdout.getWrites(); t.false( allWrites.some(w => w.includes(ansiEscapes.enterAlternativeScreen)), 'Should not write enterAlternativeScreen by default', ); t.false( allWrites.some(w => w.includes(ansiEscapes.exitAlternativeScreen)), 'Should not write exitAlternativeScreen by default', ); }); test('alternate screen - content is rendered between enter and exit', async t => { const stdout = createStdout(100, true); const {unmount, waitUntilExit} = render(Hello, { stdout, alternateScreen: true, interactive: true, }); unmount(); await waitUntilExit(); const allWrites = stdout.getWrites(); const enterIndex = allWrites.findIndex(w => w.includes(ansiEscapes.enterAlternativeScreen), ); const exitIndex = allWrites.findLastIndex(w => w.includes(ansiEscapes.exitAlternativeScreen), ); t.not(enterIndex, -1); t.not(exitIndex, -1); t.true(enterIndex < exitIndex); const contentBetween = allWrites .slice(enterIndex + 1, exitIndex) .some(w => stripAnsi(w).includes('Hello')); t.true( contentBetween, 'Rendered content should appear between enter and exit', ); }); test('alternate screen - ignored when isTTY is false', async t => { const stdout = createStdout(100, false); const {unmount, waitUntilExit} = render(Hello, { stdout, alternateScreen: true, }); unmount(); await waitUntilExit(); const allWrites = stdout.getWrites(); t.false( allWrites.some(w => w.includes(ansiEscapes.enterAlternativeScreen)), 'Should not write enterAlternativeScreen when isTTY is false', ); t.false( allWrites.some(w => w.includes(ansiEscapes.exitAlternativeScreen)), 'Should not write exitAlternativeScreen when isTTY is false', ); }); test('alternate screen - ignored when isTTY is false even if interactive is true', async t => { const stdout = createStdout(100, false); const {unmount, waitUntilExit} = render(Hello, { stdout, alternateScreen: true, interactive: true, }); unmount(); await waitUntilExit(); const allWrites = stdout.getWrites(); t.false( allWrites.some(w => w.includes(ansiEscapes.enterAlternativeScreen)), 'Should not write enterAlternativeScreen when isTTY is false, even with interactive=true', ); t.false( allWrites.some(w => w.includes(ansiEscapes.exitAlternativeScreen)), 'Should not write exitAlternativeScreen when isTTY is false, even with interactive=true', ); }); test('static output is written immediately in non-interactive mode', async t => { const stdout = createStdout(100, false); function App() { const [items, setItems] = useState(['A']); React.useEffect(() => { const timer = setTimeout(() => { setItems(['A', 'B']); }, 10); return () => { clearTimeout(timer); }; }, []); return ( {item => {item}} Dynamic ); } const {unmount, waitUntilExit} = render(, { stdout, debug: false, }); await new Promise(resolve => { setTimeout(resolve, 200); }); // Capture writes BEFORE unmount — static items must already be here const writesBeforeUnmount = stdout.getWrites().map(w => stripAnsi(w)); const preUnmountJoined = writesBeforeUnmount.join(''); t.true( preUnmountJoined.includes('A'), 'Static item A was written before unmount', ); t.true( preUnmountJoined.includes('B'), 'Static item B was written before unmount', ); unmount(); await waitUntilExit(); // Verify the dynamic content was deferred to unmount (not written before it) t.false( preUnmountJoined.includes('Dynamic'), 'Dynamic content was not written before unmount', ); // Verify dynamic content was eventually written const allWrites = stdout.getWrites().map(w => stripAnsi(w)); t.true( allWrites.join('').includes('Dynamic'), 'Dynamic content was eventually written', ); }); test('reset prop when it’s removed from the element', t => { const stdout = createStdout(); function Dynamic({remove}: {readonly remove?: boolean}) { return ( x ); } const {rerender} = render(, { stdout, debug: true, }); t.is((stdout.write as any).lastCall.args[0], '\n\n\nx'); rerender(); t.is((stdout.write as any).lastCall.args[0], 'x'); }); test('newline', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello\nWorld'); }); test('multiple newlines', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello\n\nWorld'); }); test('horizontal spacer', t => { const output = renderToString( Left Right , ); t.is(output, 'Left Right'); }); test('vertical spacer', t => { const output = renderToString( Top Bottom , ); t.is(output, 'Top\n\n\n\n\nBottom'); }); test('link ansi escapes are closed properly', t => { const output = renderToString( {ansiEscapes.link('Example', 'https://example.com')}, ); t.is(output, ']8;;https://example.comExample]8;;'); }); // Concurrent mode tests test('text - concurrent', async t => { const output = await renderToStringAsync(Hello World); t.is(output, 'Hello World'); }); test('multiple text nodes - concurrent', async t => { const output = await renderToStringAsync( {'Hello'} {' World'} , ); t.is(output, 'Hello World'); }); test('wrap text - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); t.is(output, 'Hello\nWorld'); }); test('truncate text in the end - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); t.is(output, 'Hello …'); }); test('transform children - concurrent', async t => { const output = await renderToStringAsync( `[${index}: ${string}]`} > `{${index}: ${string}}`} > test , ); t.is(output, '[0: {0: test}]'); }); test('static output - concurrent', async t => { const output = await renderToStringAsync( {letter => {letter}} X , ); t.is(output, 'A\nB\nC\n\n\nX'); }); test('remeasure text dimensions on text change - concurrent', async t => { const {getOutput, rerenderAsync} = await renderAsync( Hello , ); t.is(getOutput(), 'Hello'); await rerenderAsync( Hello World , ); t.is(getOutput(), 'Hello World'); }); test('newline - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); t.is(output, 'Hello\nWorld'); }); test('horizontal spacer - concurrent', async t => { const output = await renderToStringAsync( Left Right , ); t.is(output, 'Left Right'); }); test('vertical spacer - concurrent', async t => { const output = await renderToStringAsync( Top Bottom , ); t.is(output, 'Top\n\n\n\n\nBottom'); }); ================================================ FILE: test/cursor-helpers.tsx ================================================ import test from 'ava'; import ansiEscapes from 'ansi-escapes'; import { cursorPositionChanged, buildCursorSuffix, buildReturnToBottom, buildCursorOnlySequence, buildReturnToBottomPrefix, } from '../src/cursor-helpers.js'; const showCursorEscape = '\u001B[?25h'; const hideCursorEscape = '\u001B[?25l'; // CursorPositionChanged test('cursorPositionChanged - both undefined returns false', t => { t.false(cursorPositionChanged(undefined, undefined)); }); test('cursorPositionChanged - same position returns false', t => { t.false(cursorPositionChanged({x: 1, y: 2}, {x: 1, y: 2})); }); test('cursorPositionChanged - different x returns true', t => { t.true(cursorPositionChanged({x: 1, y: 2}, {x: 3, y: 2})); }); test('cursorPositionChanged - different y returns true', t => { t.true(cursorPositionChanged({x: 1, y: 2}, {x: 1, y: 3})); }); test('cursorPositionChanged - undefined vs defined returns true', t => { t.true(cursorPositionChanged(undefined, {x: 0, y: 0})); t.true(cursorPositionChanged({x: 0, y: 0}, undefined)); }); // BuildCursorSuffix test('buildCursorSuffix - returns empty string when cursorPosition is undefined', t => { t.is(buildCursorSuffix(3, undefined), ''); }); test('buildCursorSuffix - moves up and positions cursor', t => { const result = buildCursorSuffix(3, {x: 5, y: 1}); t.is( result, ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape, ); }); test('buildCursorSuffix - no cursorUp when cursor is at last visible line', t => { const result = buildCursorSuffix(3, {x: 0, y: 3}); t.is(result, ansiEscapes.cursorTo(0) + showCursorEscape); }); test('buildCursorSuffix - cursor at first line of single-line output', t => { const result = buildCursorSuffix(1, {x: 4, y: 0}); t.is( result, ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(4) + showCursorEscape, ); }); // BuildReturnToBottom test('buildReturnToBottom - returns empty string when previousCursorPosition is undefined', t => { t.is(buildReturnToBottom(4, undefined), ''); }); test('buildReturnToBottom - moves down to bottom', t => { const result = buildReturnToBottom(4, {x: 5, y: 0}); t.is(result, ansiEscapes.cursorDown(3) + ansiEscapes.cursorTo(0)); }); test('buildReturnToBottom - no cursorDown when cursor already at bottom', t => { const result = buildReturnToBottom(4, {x: 0, y: 3}); t.is(result, ansiEscapes.cursorTo(0)); }); // BuildCursorOnlySequence test('buildCursorOnlySequence - builds full sequence with hide prefix when cursor was shown', t => { const result = buildCursorOnlySequence({ cursorWasShown: true, previousLineCount: 2, previousCursorPosition: {x: 0, y: 0}, visibleLineCount: 1, cursorPosition: {x: 3, y: 0}, }); const expected = hideCursorEscape + buildReturnToBottom(2, {x: 0, y: 0}) + buildCursorSuffix(1, {x: 3, y: 0}); t.is(result, expected); }); test('buildCursorOnlySequence - no hide prefix when cursor was not shown', t => { const result = buildCursorOnlySequence({ cursorWasShown: false, previousLineCount: 0, previousCursorPosition: undefined, visibleLineCount: 1, cursorPosition: {x: 3, y: 0}, }); t.false(result.startsWith(hideCursorEscape)); t.true(result.includes(showCursorEscape)); }); // BuildReturnToBottomPrefix test('buildReturnToBottomPrefix - returns empty string when cursor was not shown', t => { t.is(buildReturnToBottomPrefix(false, 4, {x: 0, y: 0}), ''); }); test('buildReturnToBottomPrefix - returns hide + returnToBottom when cursor was shown', t => { const result = buildReturnToBottomPrefix(true, 4, {x: 0, y: 0}); t.is(result, hideCursorEscape + buildReturnToBottom(4, {x: 0, y: 0})); }); test('buildReturnToBottomPrefix - with undefined previousCursorPosition still hides cursor', t => { const result = buildReturnToBottomPrefix(true, 4, undefined); t.is(result, hideCursorEscape + buildReturnToBottom(4, undefined)); }); ================================================ FILE: test/cursor.tsx ================================================ import test, {type ExecutionContext} from 'ava'; import React, {Suspense, act, useEffect, useState} from 'react'; import ansiEscapes from 'ansi-escapes'; import delay from 'delay'; import { render, Box, Text, useInput, useCursor, useStdout, useStderr, } from '../src/index.js'; import {createStdin, emitReadable} from './helpers/create-stdin.js'; import createStdout from './helpers/create-stdout.js'; const showCursorEscape = '\u001B[?25h'; const hideCursorEscape = '\u001B[?25l'; const getWriteCalls = (stream: NodeJS.WriteStream): string[] => { const writes: string[] = []; for (let i = 0; i < (stream.write as any).callCount; i++) { // eslint-disable-next-line @typescript-eslint/no-unsafe-call writes.push((stream.write as any).getCall(i).args[0] as string); } return writes; }; const waitForCondition = async (condition: () => boolean): Promise => { if (condition()) { return; } const timeoutMs = 2000; const intervalMs = 10; const maxAttempts = Math.ceil(timeoutMs / intervalMs); await new Promise((resolve, reject) => { let attempts = 0; const interval = setInterval(() => { try { if (condition()) { clearInterval(interval); resolve(); return; } } catch (error) { clearInterval(interval); reject( error instanceof Error ? error : new Error('Condition check threw'), ); return; } attempts++; if (attempts >= maxAttempts) { clearInterval(interval); reject(new Error(`Condition was not met in ${timeoutMs}ms`)); } }, intervalMs); }); }; function InputApp() { const [text, setText] = useState(''); const {setCursorPosition} = useCursor(); useInput((input, key) => { if (key.backspace || key.delete) { setText(prev => prev.slice(0, -1)); return; } if (!key.ctrl && !key.meta && input) { setText(prev => prev + input); } }); setCursorPosition({x: 2 + text.length, y: 0}); return ( {`> ${text}`} ); } test.serial('cursor is shown at specified position after render', async t => { const stdout = createStdout(); const stdin = createStdin(); const {unmount} = render(, {stdout, stdin}); await delay(50); // With isTTY=true, cli-cursor writes cursor escape sequences as separate // stdout.write calls (synchronized output wrappers), so we check the // combined output of the first render rather than a single firstCall. const firstRenderOutput = getWriteCalls(stdout).join(''); // Cursor should be shown at x=2 (after "> ") t.true( firstRenderOutput.includes(showCursorEscape), 'cursor should be visible after first render', ); t.true( firstRenderOutput.includes(ansiEscapes.cursorTo(2)), 'cursor should be at column 2', ); unmount(); }); test.serial('cursor is not hidden by useEffect after first render', async t => { const stdout = createStdout(); const stdin = createStdin(); const {unmount} = render(, {stdout, stdin}); await delay(50); // Check all writes after the first render — none should be a bare hideCursorEscape // that would undo the showCursorEscape from log-update. // The last write to stdout should contain showCursorEscape (from log-update), // not be followed by a separate hideCursorEscape write from App.tsx useEffect. const output = getWriteCalls(stdout).join(''); const lastShowIndex = output.lastIndexOf(showCursorEscape); const lastHideIndex = output.lastIndexOf(hideCursorEscape); t.true( lastShowIndex > lastHideIndex, 'last cursor visibility change should be SHOW, not HIDE', ); unmount(); }); test.serial('cursor follows text input', async t => { const stdout = createStdout(); const stdin = createStdin(); const {unmount} = render(, {stdout, stdin}); await delay(50); emitReadable(stdin, 'a'); await delay(50); // With isTTY=true, stdout.get() (lastCall) may be a synchronized output // wrapper rather than the render content, so check all writes combined. const allOutput = getWriteCalls(stdout).join(''); // After typing 'a', cursor should be at x=3 ("> a" = 3 chars) t.true(allOutput.includes(showCursorEscape)); t.true( allOutput.includes(ansiEscapes.cursorTo(3)), 'cursor should move to column 3 after typing "a"', ); unmount(); }); test.serial( 'cursor moves on space input even when output is identical', async t => { const stdout = createStdout(); const stdin = createStdin(); const {unmount} = render(, {stdout, stdin}); await delay(50); emitReadable(stdin, 'a'); await delay(50); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const afterA = (stdout.write as any).callCount; emitReadable(stdin, ' '); await delay(50); // Space adds to text, cursor should move even if Ink output looks the same (padded) t.true( (stdout.write as any).callCount > afterA, 'should write to stdout after space input', ); // With isTTY=true, stdout.get() (lastCall) may be a synchronized output // wrapper rather than the render content, so check all writes combined. const allOutput = getWriteCalls(stdout).join(''); // After "a ", cursor should be at x=4 t.true( allOutput.includes(ansiEscapes.cursorTo(4)), 'cursor should be at column 4 after "a "', ); unmount(); }, ); test.serial( 'cursor is cleared when component using useCursor unmounts', async t => { const stdout = createStdout(); const stdin = createStdin(); function CursorChild() { const {setCursorPosition} = useCursor(); setCursorPosition({x: 5, y: 0}); return child; } function Parent() { const [showChild, setShowChild] = useState(true); useInput((_input, key) => { if (key.return) { setShowChild(false); } }); return {showChild ? : no cursor}; } const {unmount} = render(, {stdout, stdin}); await delay(50); // With isTTY=true, cli-cursor writes cursor escape sequences as separate // stdout.write calls, so check the combined initial render output. const initialRenderOutput = getWriteCalls(stdout).join(''); t.true( initialRenderOutput.includes(showCursorEscape), 'cursor should be visible initially', ); const writesBeforeEnter = (stdout.write as any).callCount as number; // Unmount the child by pressing Enter emitReadable(stdin, '\r'); await delay(50); // After child unmounts, cursor position should be cleared. // Only look at writes after the initial render to avoid counting // the initial render's cursor sequences. const outputAfterChildUnmount = getWriteCalls(stdout) .slice(writesBeforeEnter) .join(''); const lastShowIndex = outputAfterChildUnmount.lastIndexOf(showCursorEscape); const lastHideIndex = outputAfterChildUnmount.lastIndexOf(hideCursorEscape); t.true( lastHideIndex > lastShowIndex, 'cursor should be hidden after child with useCursor unmounts', ); unmount(); }, ); test.serial( 'cursor position does not leak from suspended concurrent render to fallback', async t => { const stdout = createStdout(); const stdin = createStdin(); let resolvePromise: () => void; const promise = new Promise(resolve => { resolvePromise = resolve; }); let suspended = true; function CursorChild() { const {setCursorPosition} = useCursor(); setCursorPosition({x: 5, y: 0}); // Render-phase side effect if (suspended) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw promise; } return loaded; } function Test() { return ( loading}> ); } await act(async () => { render(, {stdout, stdin, concurrent: true}); }); const fallbackOutput = getWriteCalls(stdout).join(''); t.true(fallbackOutput.includes('loading')); t.false( fallbackOutput.includes(showCursorEscape), 'fallback output should not contain show cursor escape from suspended concurrent render', ); // Cleanup: resolve promise and unmount suspended = false; resolvePromise!(); await act(async () => { await delay(50); }); }, ); test.serial('screen does not scroll up on subsequent renders', async t => { const stdout = createStdout(); const stdin = createStdin(); function MultiLineApp() { const [text, setText] = useState(''); const {setCursorPosition} = useCursor(); useInput((input, key) => { if (!key.ctrl && !key.meta && input) { setText(prev => prev + input); } }); setCursorPosition({x: 2 + text.length, y: 1}); return ( Header {`> ${text}`} ); } const {unmount} = render(, {stdout, stdin}); await delay(50); const writesBeforeInput = (stdout.write as any).callCount as number; emitReadable(stdin, 'x'); await delay(50); // With isTTY=true, stdout.get() (lastCall) may be a synchronized output // wrapper rather than the render content, so check writes from the // second render combined. const secondRenderOutput = getWriteCalls(stdout) .slice(writesBeforeInput) .join(''); // When cursor was at y=1 (line 1), next render should first cursorDown to bottom, // then erase. The write should contain cursorDown to return to bottom. // It should NOT just erase from cursor position (which would scroll screen up). t.true( secondRenderOutput.includes(hideCursorEscape), 'should hide cursor before erase', ); // The write should include the new text t.true( secondRenderOutput.includes('x'), 'should contain the typed character', ); unmount(); }); function StdoutWriteApp() { const {setCursorPosition} = useCursor(); const {write} = useStdout(); setCursorPosition({x: 2, y: 0}); useEffect(() => { write('from stdout hook\n'); }, [write]); return Hello; } function StderrWriteApp() { const {setCursorPosition} = useCursor(); const {write} = useStderr(); setCursorPosition({x: 2, y: 0}); useEffect(() => { write('from stderr hook\n'); }, [write]); return Hello; } type HookWriteCase = { readonly testName: string; readonly App: () => React.JSX.Element; readonly includeStderr?: boolean; readonly assertTargetWrite: ( t: ExecutionContext, output: string, stderr: NodeJS.WriteStream | undefined, ) => void; }; const hookWriteCases: HookWriteCase[] = [ { testName: 'cursor remains visible after useStdout().write()', App: StdoutWriteApp, assertTargetWrite(t, output) { t.true(output.includes('from stdout hook')); }, }, { testName: 'cursor remains visible after useStderr().write()', App: StderrWriteApp, includeStderr: true, assertTargetWrite(t, _output, stderr) { t.true((stderr?.write as any).called); }, }, ]; for (const testCase of hookWriteCases) { test.serial(testCase.testName, async t => { const stdout = createStdout(); const stdin = createStdin(); const stderr = testCase.includeStderr ? createStdout() : undefined; const {unmount} = render( , stderr ? {stdout, stderr, stdin} : {stdout, stdin}, ); await delay(50); const output = getWriteCalls(stdout).join(''); const lastShowIndex = output.lastIndexOf(showCursorEscape); const lastHideIndex = output.lastIndexOf(hideCursorEscape); testCase.assertTargetWrite(t, output, stderr); t.true( lastShowIndex > lastHideIndex, 'last cursor visibility escape should be show after hook write', ); unmount(); }); } function DebugStdoutWriteApp() { const {write} = useStdout(); useEffect(() => { write('from stdout hook\n'); }, [write]); return Hello; } function DebugStderrWriteApp() { const {write} = useStderr(); useEffect(() => { write('from stderr hook\n'); }, [write]); return Hello; } test.serial('debug mode: useStdout().write() replays latest frame', async t => { const stdout = createStdout(); const {unmount} = render(, {stdout, debug: true}); await waitForCondition(() => getWriteCalls(stdout).some(write => write.includes('from stdout hook\nHello'), ), ); const writes = getWriteCalls(stdout); const hookWrite = writes.find(write => write.includes('from stdout hook\nHello'), ); t.truthy(hookWrite); t.false(writes.includes('')); unmount(); }); test.serial( 'debug mode: useStdout().write() does not leak into stderr', async t => { const stdout = createStdout(); const stderr = createStdout(); const {unmount} = render(, { stdout, stderr, debug: true, }); await waitForCondition(() => getWriteCalls(stdout).some(write => write.includes('from stdout hook\nHello'), ), ); const stderrWrites = getWriteCalls(stderr); t.false(stderrWrites.some(write => write.includes('from stdout hook\n'))); t.false(stderrWrites.some(write => write.includes('Hello'))); t.false(stderrWrites.includes('')); unmount(); }, ); test.serial( 'debug mode: useStderr().write() replays latest frame without empty writes', async t => { const stdout = createStdout(); const stderr = createStdout(); const {unmount} = render(, { stdout, stderr, debug: true, }); await waitForCondition(() => getWriteCalls(stderr).some(write => write.includes('from stderr hook\n')), ); await waitForCondition(() => getWriteCalls(stdout).length > 1); const stdoutWrites = getWriteCalls(stdout); const stderrWrites = getWriteCalls(stderr); const stdoutWritesAfterInitialRender = stdoutWrites.slice(1); t.true(stderrWrites.some(write => write.includes('from stderr hook\n'))); t.false(stderrWrites.some(write => write.includes('Hello'))); t.true(stdoutWritesAfterInitialRender.length > 0); t.true( stdoutWritesAfterInitialRender.some(write => write.includes('Hello')), ); t.false( stdoutWritesAfterInitialRender.some(write => write.includes('from stderr hook\n'), ), ); t.false(stdoutWrites.includes('')); t.false(stderrWrites.includes('')); unmount(); }, ); function DebugStderrWriteAfterRerenderApp() { const [text, setText] = useState('Initial'); const {write} = useStderr(); useEffect(() => { setText('Updated'); }, []); useEffect(() => { if (text === 'Updated') { write('from stderr hook\n'); } }, [text, write]); return {text}; } function DebugStdoutWriteAfterRerenderApp() { const [text, setText] = useState('Initial'); const {write} = useStdout(); useEffect(() => { setText('Updated'); }, []); useEffect(() => { if (text === 'Updated') { write('from stdout hook\n'); } }, [text, write]); return {text}; } test.serial( 'debug mode: useStdout().write() replays rerendered frame', async t => { const stdout = createStdout(); const {unmount} = render(, { stdout, debug: true, }); await waitForCondition(() => getWriteCalls(stdout).some(write => write.includes('from stdout hook\nUpdated'), ), ); const stdoutWrites = getWriteCalls(stdout); t.true( stdoutWrites.some(write => write.includes('from stdout hook\nUpdated')), ); t.false( stdoutWrites.some(write => write.includes('from stdout hook\nInitial')), ); t.false(stdoutWrites.includes('')); unmount(); }, ); test.serial( 'debug mode: useStderr().write() replays rerendered frame', async t => { const stdout = createStdout(); const stderr = createStdout(); const {unmount} = render(, { stdout, stderr, debug: true, }); await waitForCondition(() => getWriteCalls(stderr).some(write => write.includes('from stderr hook\n')), ); await waitForCondition(() => getWriteCalls(stdout) .slice(1) .some(write => write.includes('Updated')), ); const stdoutWrites = getWriteCalls(stdout); const stderrWrites = getWriteCalls(stderr); const stdoutWritesAfterInitialRender = stdoutWrites.slice(1); t.true(stderrWrites.some(write => write.includes('from stderr hook\n'))); t.false(stderrWrites.some(write => write.includes('Updated'))); t.false(stderrWrites.some(write => write.includes('Initial'))); t.true( stdoutWritesAfterInitialRender.some(write => write.includes('Updated')), ); t.false( stdoutWritesAfterInitialRender.some(write => write.includes('Initial')), ); t.false( stdoutWritesAfterInitialRender.some(write => write.includes('from stderr hook\n'), ), ); t.false(stdoutWrites.includes('')); t.false(stderrWrites.includes('')); unmount(); }, ); ================================================ FILE: test/display.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; test('display flex', t => { const output = renderToString( X , ); t.is(output, 'X'); }); test('display none', t => { const output = renderToString( Kitty! Doggo , ); t.is(output, 'Doggo'); }); // Concurrent mode tests test('display flex - concurrent', async t => { const output = await renderToStringAsync( X , ); t.is(output, 'X'); }); test('display none - concurrent', async t => { const output = await renderToStringAsync( Kitty! Doggo , ); t.is(output, 'Doggo'); }); ================================================ FILE: test/errors.tsx ================================================ import process from 'node:process'; import React, {useEffect} from 'react'; import test from 'ava'; import patchConsole from 'patch-console'; import stripAnsi from 'strip-ansi'; import {render, useStdin, Text} from '../src/index.js'; import createStdout from './helpers/create-stdout.js'; let restore = () => {}; test.before(() => { restore = patchConsole(() => {}); }); test.after(() => { restore(); }); test('catch and display error', t => { const stdout = createStdout(); const Test = () => { throw new Error('Oh no'); }; render(, {stdout}); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const writes: string[] = (stdout.write as any) .getCalls() .map((c: any) => c.args[0] as string) .filter( (w: string) => !w.startsWith('\u001B[?25') && !w.startsWith('\u001B[?2026'), ); const lastContentWrite = writes.at(-1)!; t.deepEqual(stripAnsi(lastContentWrite).split('\n').slice(0, 14), [ '', ' ERROR Oh no', '', ' test/errors.tsx:23:9', '', ' 20: const stdout = createStdout();', ' 21:', ' 22: const Test = () => {', " 23: throw new Error('Oh no');", ' 24: };', ' 25:', ' 26: render(, {stdout});', '', ' - Test (test/errors.tsx:23:9)', ]); }); test.serial( 'does not emit unhandledRejection when render exits with an error and waitUntilExit is unused', async t => { const stdout = createStdout(); const unhandledRejectionReasons: unknown[] = []; const onUnhandledRejection = (reason: unknown) => { unhandledRejectionReasons.push(reason); }; process.on('unhandledRejection', onUnhandledRejection); try { const Test = () => { throw new Error('Oh no'); }; render(, {stdout}); await new Promise(resolve => { setImmediate(resolve); }); await new Promise(resolve => { setImmediate(resolve); }); t.is(unhandledRejectionReasons.length, 0); } finally { process.off('unhandledRejection', onUnhandledRejection); } }, ); test('ErrorBoundary catches and displays nested component errors', t => { const stdout = createStdout(); const NestedComponent = () => { throw new Error('Nested component error'); }; function Parent() { return ( Before error ); } render(, {stdout}); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const writes: string[] = (stdout.write as any) .getCalls() .map((c: any) => c.args[0] as string) .filter( (w: string) => !w.startsWith('\u001B[?25') && !w.startsWith('\u001B[?2026'), ); const lastContentWrite = writes.at(-1)!; const output = stripAnsi(lastContentWrite); t.true(output.includes('ERROR'), 'Error label should be displayed'); t.true( output.includes('Nested component error'), 'Error message should be shown', ); }); test('clean up raw mode when error is thrown', async t => { const stdout = createStdout(); // Track setRawMode calls const setRawModeCalls: boolean[] = []; const originalSetRawMode = process.stdin.setRawMode?.bind(process.stdin); // Only run this test if raw mode is supported if (!process.stdin.isTTY) { t.pass('Skipping test - stdin is not a TTY'); return; } process.stdin.setRawMode = (mode: boolean) => { setRawModeCalls.push(mode); return originalSetRawMode?.(mode) ?? process.stdin; }; function Test() { const {setRawMode} = useStdin(); useEffect(() => { setRawMode(true); // Throw after enabling raw mode throw new Error('Error after raw mode enabled'); }, [setRawMode]); return Test; } const app = render(, {stdout}); await t.throwsAsync(app.waitUntilExit()); // Restore original setRawMode if (originalSetRawMode) { process.stdin.setRawMode = originalSetRawMode; } // Verify raw mode was enabled then disabled t.true(setRawModeCalls.includes(true), 'Raw mode should have been enabled'); t.true( setRawModeCalls.includes(false), 'Raw mode should have been disabled on cleanup', ); }); ================================================ FILE: test/exit.tsx ================================================ import process from 'node:process'; import * as path from 'node:path'; import url from 'node:url'; import {createRequire} from 'node:module'; import test from 'ava'; import stripAnsi from 'strip-ansi'; import {run} from './helpers/run.js'; const require = createRequire(import.meta.url); // eslint-disable-next-line @typescript-eslint/consistent-type-imports const {spawn} = require('node-pty') as typeof import('node-pty'); const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); test.serial('exit normally without unmount() or exit()', async t => { const output = await run('exit-normally'); t.true(output.includes('exited')); }); test.serial('exit on unmount()', async t => { const output = await run('exit-on-unmount'); t.true(output.includes('exited')); }); test.serial('exit when app finishes execution', async t => { const ps = run('exit-on-finish'); await t.notThrowsAsync(ps); }); test.serial('exit on exit()', async t => { const output = await run('exit-on-exit'); t.true(output.includes('exited')); }); test.serial('exit on exit() with error', async t => { const output = await run('exit-on-exit-with-error'); t.true(output.includes('errored')); }); test.serial('exit on exit() with error with value property', async t => { const output = await run('exit-on-exit-with-error-value-property'); t.true(output.includes('errored')); }); test.serial('exit on exit() with result value', async t => { const output = await run('exit-on-exit-with-result'); t.true(output.includes('result:hello from ink')); }); test.serial('exit on exit() with object result', async t => { const output = await run('exit-on-exit-with-value-object'); t.true(output.includes('result:hello from ink object')); }); test.serial('exit on exit() with raw mode', async t => { const output = await run('exit-raw-on-exit'); t.true(output.includes('exited')); }); test.serial('exit on exit() with raw mode with error', async t => { const output = await run('exit-raw-on-exit-with-error'); t.true(output.includes('errored')); }); test.serial('exit on unmount() with raw mode', async t => { const output = await run('exit-raw-on-unmount'); t.true(output.includes('exited')); }); test.serial('exit with thrown error', async t => { const output = await run('exit-with-thrown-error'); t.true(output.includes('errored')); }); test.serial('don’t exit while raw mode is active', async t => { await new Promise((resolve, reject) => { const env: Record = { ...process.env, // eslint-disable-next-line @typescript-eslint/naming-convention NODE_NO_WARNINGS: '1', }; const term = spawn( 'node', [ '--import=tsx', path.join(__dirname, './fixtures/exit-double-raw-mode.tsx'), ], { name: 'xterm-color', cols: 100, cwd: __dirname, env, }, ); let output = ''; term.onData(data => { if (data === 's') { setTimeout(() => { t.false(isExited); term.write('q'); }, 500); setTimeout(() => { term.kill(); reject(new Error('Test timed out - process did not exit in time')); }, 2000); } else { output += data; } }); let isExited = false; term.onExit(({exitCode}) => { isExited = true; if (exitCode === 0) { t.true(output.includes('exited')); t.pass(); resolve(); return; } reject(new Error(`Process exited with code ${exitCode}`)); }); }); }); test.serial('exit when DEV is set', async t => { const output = await run('exit-normally', { env: { // eslint-disable-next-line @typescript-eslint/naming-convention DEV: 'true', }, }); // Warning output depends on whether a local React DevTools server is running. t.true(output.includes('exited')); }); test.serial('exit on exit() with error and static output', async t => { const output = await run('exit-with-static'); // Error is propagated, not swallowed t.true(output.includes('errored')); // Static items rendered t.true(output.includes('A')); t.true(output.includes('B')); t.true(output.includes('C')); // Static items NOT duplicated (the bug from #397) const cleaned = stripAnsi(output); t.is(cleaned.split('A').length - 1, 1); }); ================================================ FILE: test/fixtures/alternate-screen-full-board-win.tsx ================================================ import {gameReducer} from '../../examples/alternate-screen/alternate-screen.js'; const boardWidth = 20; const boardHeight = 15; const initialSnakeLength = 3; const snake = []; for (let y = 0; y < boardHeight; y++) { if (y % 2 === 0) { for (let x = 0; x < boardWidth; x++) { if (x === 0 && y === 0) { continue; } snake.push({x, y}); } } else { for (let x = boardWidth - 1; x >= 0; x--) { snake.push({x, y}); } } } const nextState = gameReducer( { snake, food: {x: 0, y: 0}, score: snake.length - initialSnakeLength, gameOver: false, won: false, frame: 42, }, { type: 'tick', direction: 'left', }, ); console.log( JSON.stringify({ gameOver: nextState.gameOver, won: nextState.won, score: nextState.score, snakeLength: nextState.snake.length, }), ); ================================================ FILE: test/fixtures/ci-debug-after-exit.tsx ================================================ import process from 'node:process'; import React from 'react'; import {render, Text} from '../../src/index.js'; const app = render(Hello, {debug: true}); app.unmount(); await app.waitUntilExit(); process.stdout.write('DONE'); ================================================ FILE: test/fixtures/ci-debug.tsx ================================================ import React from 'react'; import {render, Text} from '../../src/index.js'; render(Hello, {debug: true}); ================================================ FILE: test/fixtures/ci.tsx ================================================ import React from 'react'; import {render, Static, Text} from '../../src/index.js'; type TestState = { counter: number; items: string[]; }; class Test extends React.Component, TestState> { timer?: NodeJS.Timeout; override state: TestState = { items: [], counter: 0, }; override render() { return ( <> {item => {item}} Counter: {this.state.counter} ); } override componentDidMount() { const onTimeout = () => { if (this.state.counter > 4) { return; } this.setState(prevState => ({ counter: prevState.counter + 1, items: [...prevState.items, `#${prevState.counter + 1}`], })); this.timer = setTimeout(onTimeout, 20); }; this.timer = setTimeout(onTimeout, 20); } override componentWillUnmount() { clearTimeout(this.timer); } } render(); ================================================ FILE: test/fixtures/clear.tsx ================================================ import React from 'react'; import {Box, Text, render} from '../../src/index.js'; function Clear() { return ( A B C ); } const {clear} = render(); clear(); ================================================ FILE: test/fixtures/console.tsx ================================================ import React, {useEffect} from 'react'; import {Text, render} from '../../src/index.js'; function App() { useEffect(() => { const timer = setTimeout(() => {}, 1000); return () => { clearTimeout(timer); }; }, []); return Hello World; } const {unmount} = render(); console.log('First log'); unmount(); console.log('Second log'); ================================================ FILE: test/fixtures/erase-with-state-change.tsx ================================================ import process from 'node:process'; import React, {useEffect, useState} from 'react'; import {Box, Text, render} from '../../src/index.js'; function Erase() { const [show, setShow] = useState(true); useEffect(() => { const timer = setTimeout(() => { setShow(false); }); return () => { clearTimeout(timer); }; }, []); return ( {show ? ( <> A B C ) : null} ); } process.stdout.rows = Number(process.argv[2]); render(); ================================================ FILE: test/fixtures/erase-with-static.tsx ================================================ import process from 'node:process'; import React from 'react'; import {Static, Box, Text, render} from '../../src/index.js'; function EraseWithStatic() { return ( <> {item => {item}} D E F ); } process.stdout.rows = Number(process.argv[3]); render(); ================================================ FILE: test/fixtures/erase.tsx ================================================ import process from 'node:process'; import React from 'react'; import {Box, Text, render} from '../../src/index.js'; function Erase() { return ( A B C ); } process.stdout.rows = Number(process.argv[2]); render(); ================================================ FILE: test/fixtures/exit-double-raw-mode.tsx ================================================ import process from 'node:process'; import React from 'react'; import {Text, render, useStdin} from '../../src/index.js'; class ExitDoubleRawMode extends React.Component<{ setRawMode: (value: boolean) => void; }> { override render() { return Hello World; } override componentDidMount() { const {setRawMode} = this.props; setRawMode(true); setTimeout(() => { setRawMode(false); setRawMode(true); // Start the test process.stdout.write('s'); }, 500); } } function Test() { const {setRawMode} = useStdin(); return ; } const {unmount, waitUntilExit} = render(); process.stdin.on('data', data => { if (String(data) === 'q') { unmount(); } }); await waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/exit-normally.tsx ================================================ import React from 'react'; import {Text, render} from '../../src/index.js'; const {waitUntilExit} = render(Hello World); await waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/exit-on-exit-with-error-value-property.tsx ================================================ import React, {useEffect} from 'react'; import {render, Text, useApp} from '../../src/index.js'; function Test() { const {exit} = useApp(); useEffect(() => { setTimeout(() => { const error = new Error('errored'); (error as Error & {value: string}).value = 'hello from error'; exit(error); }, 500); }, []); return Testing; } const app = render(); try { await app.waitUntilExit(); } catch (error: unknown) { console.log((error as Error).message); } ================================================ FILE: test/fixtures/exit-on-exit-with-error.tsx ================================================ import React from 'react'; import {render, Text, useApp} from '../../src/index.js'; class Exit extends React.Component< {onExit: (error: Error) => void}, {counter: number} > { timer?: NodeJS.Timeout; override state = { counter: 0, }; override render() { return Counter: {this.state.counter}; } override componentDidMount() { setTimeout(() => { this.props.onExit(new Error('errored')); }, 500); this.timer = setInterval(() => { this.setState(prevState => ({ counter: prevState.counter + 1, })); }, 100); } override componentWillUnmount() { clearInterval(this.timer); } } function Test() { const {exit} = useApp(); return ; } const app = render(); try { await app.waitUntilExit(); } catch (error: unknown) { console.log((error as any).message); } ================================================ FILE: test/fixtures/exit-on-exit-with-result.tsx ================================================ import React, {useEffect} from 'react'; import {render, Text, useApp} from '../../src/index.js'; function Test() { const {exit} = useApp(); useEffect(() => { setTimeout(() => { exit('hello from ink'); }, 500); }); return Testing; } const app = render(); const result = await app.waitUntilExit(); console.log(`result:${String(result)}`); ================================================ FILE: test/fixtures/exit-on-exit-with-value-object.tsx ================================================ import React, {useEffect} from 'react'; import {render, Text, useApp} from '../../src/index.js'; function Test() { const {exit} = useApp(); useEffect(() => { setTimeout(() => { exit({message: 'hello from ink object'}); }, 500); }); return Testing; } const app = render(); const result = await app.waitUntilExit(); console.log(`result:${(result as {message: string}).message}`); ================================================ FILE: test/fixtures/exit-on-exit.tsx ================================================ import React from 'react'; import {render, Text, useApp} from '../../src/index.js'; class Exit extends React.Component< {onExit: (error: Error) => void}, {counter: number} > { timer?: NodeJS.Timeout; override state = { counter: 0, }; override render() { return Counter: {this.state.counter}; } override componentDidMount() { setTimeout(this.props.onExit, 500); this.timer = setInterval(() => { this.setState(prevState => ({ counter: prevState.counter + 1, })); }, 100); } override componentWillUnmount() { clearInterval(this.timer); } } function Test() { const {exit} = useApp(); return ; } const app = render(); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/exit-on-finish.tsx ================================================ import React from 'react'; import {render, Text} from '../../src/index.js'; class Test extends React.Component, {counter: number}> { timer?: NodeJS.Timeout; override state = { counter: 0, }; override render() { return Counter: {this.state.counter}; } override componentDidMount() { const onTimeout = () => { if (this.state.counter > 4) { return; } this.setState(prevState => ({ counter: prevState.counter + 1, })); this.timer = setTimeout(onTimeout, 20); }; this.timer = setTimeout(onTimeout, 20); } override componentWillUnmount() { clearTimeout(this.timer); } } render(); ================================================ FILE: test/fixtures/exit-on-unmount.tsx ================================================ import React from 'react'; import {render, Text} from '../../src/index.js'; class Test extends React.Component, {counter: number}> { timer?: NodeJS.Timeout; override state = { counter: 0, }; override render() { return Counter: {this.state.counter}; } override componentDidMount() { this.timer = setInterval(() => { this.setState(prevState => ({ counter: prevState.counter + 1, })); }, 100); } override componentWillUnmount() { clearInterval(this.timer); } } const app = render(); setTimeout(() => { app.unmount(); }, 500); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/exit-raw-on-exit-with-error.tsx ================================================ import React from 'react'; import {render, Text, useApp, useStdin} from '../../src/index.js'; class Exit extends React.Component<{ onSetRawMode: (value: boolean) => void; onExit: (error: Error) => void; }> { override render() { return Hello World; } override componentDidMount() { this.props.onSetRawMode(true); setTimeout(() => { this.props.onExit(new Error('errored')); }, 500); } } function Test() { const {exit} = useApp(); const {setRawMode} = useStdin(); return ; } const app = render(); try { await app.waitUntilExit(); } catch (error: unknown) { console.log((error as any).message); } ================================================ FILE: test/fixtures/exit-raw-on-exit.tsx ================================================ import React from 'react'; import {render, Text, useApp, useStdin} from '../../src/index.js'; class Exit extends React.Component<{ onSetRawMode: (value: boolean) => void; onExit: (error: Error) => void; }> { override render() { return Hello World; } override componentDidMount() { this.props.onSetRawMode(true); setTimeout(this.props.onExit, 500); } } function Test() { const {exit} = useApp(); const {setRawMode} = useStdin(); return ; } const app = render(); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/exit-raw-on-unmount.tsx ================================================ import React from 'react'; import {render, Text, useStdin} from '../../src/index.js'; class Exit extends React.Component<{ onSetRawMode: (value: boolean) => void; }> { override render() { return Hello World; } override componentDidMount() { this.props.onSetRawMode(true); } } function Test() { const {setRawMode} = useStdin(); return ; } const app = render(); setTimeout(() => { app.unmount(); }, 500); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/exit-with-static.tsx ================================================ import React, {useEffect} from 'react'; import {render, Static, Text, useApp} from '../../src/index.js'; function Test() { const {exit} = useApp(); useEffect(() => { exit(new Error('errored')); }, []); return ( <> {item => {item}} Dynamic ); } const app = render(); try { await app.waitUntilExit(); } catch (error: unknown) { console.log((error as Error).message); } ================================================ FILE: test/fixtures/exit-with-thrown-error.tsx ================================================ import React from 'react'; import {render} from '../../src/index.js'; const Test = () => { throw new Error('errored'); }; const app = render(); try { await app.waitUntilExit(); } catch (error: unknown) { console.log((error as any).message); } ================================================ FILE: test/fixtures/fullscreen-no-extra-newline.tsx ================================================ import process from 'node:process'; import React, {useEffect} from 'react'; import {Box, Text, render, useApp} from '../../src/index.js'; function Fullscreen() { const {exit} = useApp(); useEffect(() => { // Exit after first render to check the output const timer = setTimeout(() => { exit(); }, 100); return () => { clearTimeout(timer); }; }, [exit]); // Force the root to occupy exactly terminal rows const rows = Number(process.argv[2]) || 5; return ( Full-screen: top Bottom line (should be usable) ); } // Set terminal size from argument process.stdout.rows = Number(process.argv[2]) || 5; render(); ================================================ FILE: test/fixtures/issue-442-full-height.tsx ================================================ import process from 'node:process'; import React, {useEffect} from 'react'; import {Box, Text, render, useApp} from '../../src/index.js'; function App() { const {exit} = useApp(); useEffect(() => { const timer = setTimeout(() => { exit(); }, 100); return () => { clearTimeout(timer); }; }, [exit]); const rows = Number(process.argv[2]) || 5; const columns = process.stdout.columns || 100; return ( #442 top #442 bottom ); } process.stdout.rows = Number(process.argv[2]) || 5; render(); ================================================ FILE: test/fixtures/issue-450-fixture-helpers.tsx ================================================ import process from 'node:process'; import React, {useEffect, useState} from 'react'; import {Box, Static, Text, render, useApp} from '../../src/index.js'; type RerenderFixtureOptions = { readonly completionMarker?: string; readonly frameLimit?: number; readonly includeStaticLine?: boolean; readonly rowsFallback?: number; readonly heightForFrame: (rows: number, frameCount: number) => number; }; function Issue450RerenderFixtureComponent({ completionMarker, frameLimit, includeStaticLine, heightForFrame, rows, }: { readonly completionMarker?: string; readonly frameLimit: number; readonly includeStaticLine: boolean; readonly heightForFrame: (rows: number, frameCount: number) => number; readonly rows: number; }) { const {exit} = useApp(); const [frameCount, setFrameCount] = useState(0); const targetHeight = heightForFrame(rows, frameCount); useEffect(() => { if (frameCount >= frameLimit) { const timer = setTimeout(() => { if (completionMarker) { process.stdout.write(completionMarker); } exit(); }, 0); return () => { clearTimeout(timer); }; } const timer = setTimeout(() => { setFrameCount(previousFrameCount => previousFrameCount + 1); }, 100); return () => { clearTimeout(timer); }; }, [completionMarker, exit, frameCount, frameLimit]); return ( <> {includeStaticLine ? ( {item => {item}} ) : null} #450 top {`frame ${frameCount}`} #450 bottom ); } export const runIssue450RerenderFixture = ({ completionMarker, frameLimit = 8, includeStaticLine = false, rowsFallback = 6, heightForFrame, }: RerenderFixtureOptions): void => { const rows = Number(process.argv[2]) || rowsFallback; process.stdout.rows = rows; render( , ); }; type InitialFixtureOptions = { readonly rowsFallback?: number; readonly renderedMarker: string; readonly lineCount: number; readonly linePrefix: string; }; function Issue450InitialFixtureComponent({ renderedMarker, lineCount, linePrefix, }: { readonly renderedMarker: string; readonly lineCount: number; readonly linePrefix: string; }) { const {exit} = useApp(); useEffect(() => { const timer = setTimeout(() => { process.stdout.write(renderedMarker); exit(); }, 0); return () => { clearTimeout(timer); }; }, [exit, renderedMarker]); const lines = []; for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) { lines.push( {`${linePrefix} line ${lineNumber}`}, ); } return {lines}; } export const runIssue450InitialFixture = ({ rowsFallback = 3, renderedMarker, lineCount, linePrefix, }: InitialFixtureOptions): void => { const rows = Number(process.argv[2]) || rowsFallback; process.stdout.rows = rows; render( , ); }; ================================================ FILE: test/fixtures/issue-450-full-height-rerender-with-marker.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ completionMarker: '__FULL_HEIGHT_RERENDER_COMPLETED__', heightForFrame: rows => rows, }); ================================================ FILE: test/fixtures/issue-450-full-height-rerender.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ heightForFrame: rows => rows, }); ================================================ FILE: test/fixtures/issue-450-full-height-with-static-rerender.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ includeStaticLine: true, heightForFrame: rows => rows, }); ================================================ FILE: test/fixtures/issue-450-grow-to-fullscreen-rerender.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ completionMarker: '__GROW_TO_FULLSCREEN_RERENDER_COMPLETED__', heightForFrame: (rows, frameCount) => (frameCount < 2 ? rows - 1 : rows), }); ================================================ FILE: test/fixtures/issue-450-grow-to-overflow-rerender.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ frameLimit: 1, rowsFallback: 3, heightForFrame: (rows, frameCount) => frameCount === 0 ? rows - 1 : rows + 1, }); ================================================ FILE: test/fixtures/issue-450-height-minus-one-rerender.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ heightForFrame: rows => rows - 1, }); ================================================ FILE: test/fixtures/issue-450-initial-fullscreen.tsx ================================================ import {runIssue450InitialFixture} from './issue-450-fixture-helpers.js'; runIssue450InitialFixture({ renderedMarker: '__INITIAL_FULLSCREEN_FRAME_RENDERED__', lineCount: 3, linePrefix: '#450 initial fullscreen', }); ================================================ FILE: test/fixtures/issue-450-initial-overflow.tsx ================================================ import {runIssue450InitialFixture} from './issue-450-fixture-helpers.js'; runIssue450InitialFixture({ renderedMarker: '__INITIAL_OVERFLOW_FRAME_RENDERED__', lineCount: 4, linePrefix: '#450 initial overflow', }); ================================================ FILE: test/fixtures/issue-450-shrink-from-fullscreen-rerender.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ heightForFrame: (rows, frameCount) => (frameCount < 2 ? rows : rows - 1), }); ================================================ FILE: test/fixtures/issue-450-shrink-from-overflow-rerender.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ heightForFrame: (rows, frameCount) => frameCount === 0 ? rows + 1 : rows - 1, }); ================================================ FILE: test/fixtures/issue-450-static-shrink-from-fullscreen-rerender.tsx ================================================ import {runIssue450RerenderFixture} from './issue-450-fixture-helpers.js'; runIssue450RerenderFixture({ includeStaticLine: true, heightForFrame: (rows, frameCount) => (frameCount < 2 ? rows : rows - 1), }); ================================================ FILE: test/fixtures/issue-725-child-process.tsx ================================================ import React from 'react'; import {Text, useStdin, render} from '../../src/index.js'; function App() { const {isRawModeSupported} = useStdin(); return {isRawModeSupported ? 'ready' : 'ready-stdin-not-tty'}; } const {waitUntilExit} = render(); await waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/use-input-ctrl-c.tsx ================================================ import process from 'node:process'; import React from 'react'; import {render, useInput, useApp} from '../../src/index.js'; function UserInput() { const {exit} = useApp(); useInput((input, key) => { if (input === 'c' && key.ctrl) { exit(); return; } throw new Error('Crash'); }); React.useEffect(() => { process.stdout.write('__READY__'); }, []); return null; } const app = render(, {exitOnCtrlC: false}); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/use-input-discrete-priority.tsx ================================================ import process from 'node:process'; import React, { useState, useTransition, useMemo, useEffect, useRef, } from 'react'; import {render, Box, Text, useInput, useApp} from '../../src/index.js'; function App() { const {exit} = useApp(); const [query, setQuery] = useState('abcde'); const [, startTransition] = useTransition(); const [deferredQuery, setDeferredQuery] = useState('abcde'); const done = useRef(false); useInput((input, key) => { if (key.return) { if (done.current) { return; } done.current = true; process.stdout.write( `\nFINAL query:${JSON.stringify(query)} deferred:${JSON.stringify(deferredQuery)}\n`, ); exit(); return; } if (key.backspace || key.delete) { setQuery(previousQuery => previousQuery.slice(0, -1)); startTransition(() => { setDeferredQuery(previousQuery => previousQuery.slice(0, -1)); }); } }); const filteredResult = useMemo(() => { if (!deferredQuery) { return ''; } // Simulate expensive computation that blocks the fiber const start = Date.now(); while (Date.now() - start < 30) { // Artificial delay } return deferredQuery; }, [deferredQuery]); useEffect(() => { process.stdout.write('__READY__'); }, []); return ( query:{query} deferred:{deferredQuery} filtered:{filteredResult} ); } render(, {concurrent: true}); ================================================ FILE: test/fixtures/use-input-kitty.tsx ================================================ import process from 'node:process'; import React from 'react'; import {render, useInput, useApp} from '../../src/index.js'; function UserInput({test}: {readonly test: string | undefined}) { const {exit} = useApp(); useInput((input, key) => { // Test super modifier (Cmd on Mac, Win on Windows) if (test === 'super' && key.super && input === 's') { exit(); return; } // Test hyper modifier if (test === 'hyper' && key.hyper && input === 'h') { exit(); return; } // Test capsLock if (test === 'capsLock' && key.capsLock) { exit(); return; } // Test numLock if (test === 'numLock' && key.numLock) { exit(); return; } // Test super+ctrl combination if (test === 'superCtrl' && key.super && key.ctrl && input === 's') { exit(); return; } // Test repeat event type if (test === 'repeat' && key.eventType === 'repeat') { exit(); return; } // Test release event type if (test === 'release' && key.eventType === 'release') { exit(); return; } // Test press event type (default) if (test === 'press' && key.eventType === 'press' && input === 'a') { exit(); return; } // Test escape with kitty protocol if (test === 'escapeKitty' && key.escape) { exit(); return; } // Test non-printable keys produce empty input if (test === 'nonPrintable' && input === '') { exit(); return; } // Test ctrl+letter via codepoint 1-26 form still provides input if (test === 'ctrlLetter' && input === 'a' && key.ctrl) { exit(); return; } // Test space produces space character as input if (test === 'space' && input === ' ') { exit(); return; } // Test return produces carriage return as input if (test === 'returnKey' && input === '\r') { exit(); return; } throw new Error(`Unexpected input: ${JSON.stringify({input, key})}`); }); React.useEffect(() => { process.stdout.write('__READY__'); }, []); return null; } const app = render(, { kittyKeyboard: {mode: 'disabled'}, // Disable auto-detection for tests }); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/use-input-many.tsx ================================================ import process from 'node:process'; import React, {useEffect} from 'react'; import {render, useInput, useApp, Text} from '../../src/index.js'; // Detect MaxListenersExceededWarning process.on('warning', warning => { if (warning.name === 'MaxListenersExceededWarning') { console.log('MaxListenersExceededWarning'); } }); function InputHandler() { useInput(() => {}); return null; } function App() { const {exit} = useApp(); useEffect(() => { setTimeout(exit, 100); }, []); return ( <> ready ); } const app = render(); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/use-input-multiple.tsx ================================================ import process from 'node:process'; import React, {useState, useCallback, useEffect} from 'react'; import {render, useInput, useApp, Text} from '../../src/index.js'; function App() { const {exit} = useApp(); const [input, setInput] = useState(''); const handleInput = useCallback((input: string) => { setInput((previousInput: string) => previousInput + input); }, []); useInput(handleInput); useInput(handleInput, {isActive: false}); useEffect(() => { process.stdout.write('__READY__'); }, []); useEffect(() => { setTimeout(exit, 100); }, []); return {input}; } const app = render(); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/use-input.tsx ================================================ import process from 'node:process'; import React from 'react'; import {render, useInput, useApp} from '../../src/index.js'; function UserInput({test}: {readonly test: string | undefined}) { const {exit} = useApp(); const rapidDownArrowCountRef = React.useRef(0); React.useEffect(() => { if (test !== 'rapidArrowsEnter') { return; } const timeout = setTimeout(() => { throw new Error( `Expected 3 down arrows and enter, received ${rapidDownArrowCountRef.current} down arrow events`, ); }, 6000); return () => { clearTimeout(timeout); }; }, [test]); useInput((input, key) => { if (test === 'rapidArrowsEnter') { if (key.downArrow) { rapidDownArrowCountRef.current++; return; } if (key.return) { if (rapidDownArrowCountRef.current === 3) { exit(); return; } throw new Error( `Expected enter after 3 down arrows, received ${rapidDownArrowCountRef.current}`, ); } throw new Error('Expected only down arrows and enter'); } if (test === 'lowercase' && input === 'q') { exit(); return; } if (test === 'uppercase' && input === 'Q' && key.shift) { exit(); return; } if (test === 'uppercase' && input === '\r' && !key.shift) { exit(); return; } if (test === 'pastedCarriageReturn' && input === '\rtest') { exit(); return; } if (test === 'pastedTab' && input === '\ttest') { exit(); return; } if (test === 'bracketedPaste' && input === 'hello') { exit(); return; } if (test === 'escape' && key.escape) { exit(); return; } if (test === 'ctrl' && input === 'f' && key.ctrl) { exit(); return; } if (test === 'meta' && input === 'm' && key.meta) { exit(); return; } if (test === 'escapeBracketPrefix' && input === '[' && !key.meta) { exit(); return; } if (test === 'metaUpperO' && input === 'O' && key.meta) { exit(); return; } if (test === 'upArrow' && key.upArrow && !key.meta) { exit(); return; } if (test === 'downArrow' && key.downArrow && !key.meta) { exit(); return; } if (test === 'leftArrow' && key.leftArrow && !key.meta) { exit(); return; } if (test === 'rightArrow' && key.rightArrow && !key.meta) { exit(); return; } if (test === 'upArrowMeta' && key.upArrow && key.meta) { exit(); return; } if (test === 'downArrowMeta' && key.downArrow && key.meta) { exit(); return; } if (test === 'leftArrowMeta' && key.leftArrow && key.meta) { exit(); return; } if (test === 'rightArrowMeta' && key.rightArrow && key.meta) { exit(); return; } if (test === 'upArrowCtrl' && key.upArrow && key.ctrl) { exit(); return; } if (test === 'downArrowCtrl' && key.downArrow && key.ctrl) { exit(); return; } if (test === 'leftArrowCtrl' && key.leftArrow && key.ctrl) { exit(); return; } if (test === 'rightArrowCtrl' && key.rightArrow && key.ctrl) { exit(); return; } if (test === 'pageDown' && key.pageDown && !key.meta) { exit(); return; } if (test === 'pageUp' && key.pageUp && !key.meta) { exit(); return; } if (test === 'home' && key.home && !key.meta) { exit(); return; } if (test === 'end' && key.end && !key.meta) { exit(); return; } if (test === 'tab' && input === '' && key.tab && !key.ctrl) { exit(); return; } if (test === 'shiftTab' && input === '' && key.tab && key.shift) { exit(); return; } if (test === 'backspace' && input === '' && key.backspace) { exit(); return; } if (test === 'delete' && input === '' && key.delete) { exit(); return; } if (test === 'remove' && input === '' && key.delete) { exit(); return; } if (test === 'returnMeta' && key.return && key.meta) { exit(); return; } throw new Error('Crash'); }); React.useEffect(() => { process.stdout.write('__READY__'); }, []); return null; } const app = render(); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/use-paste.tsx ================================================ import process from 'node:process'; import React from 'react'; import {render, useApp, useInput, usePaste} from '../../src/index.js'; function PasteDemo({test}: {readonly test: string | undefined}) { const {exit} = useApp(); usePaste(text => { if (test === 'basic' && text === 'hello world') { exit(); return; } if (test === 'escapeSequences' && text === 'hello\u001B[Aworld') { exit(); return; } if (test === 'noUseInput' && text === 'hello') { exit(); } }); useInput( input => { throw new Error( `useInput received input during paste: ${JSON.stringify(input)}`, ); }, {isActive: test === 'noUseInput'}, ); React.useEffect(() => { process.stdout.write('__READY__'); }, []); return null; } function MultipleHooksDemo() { const {exit} = useApp(); const receivedCount = React.useRef(0); const onPaste = React.useCallback( (text: string) => { if (text === 'hello') { receivedCount.current++; if (receivedCount.current >= 2) { exit(); } } }, [exit], ); usePaste(onPaste); usePaste(onPaste); React.useEffect(() => { process.stdout.write('__READY__'); }, []); return null; } const test = process.argv[2]; const app = render( test === 'multipleHooks' ? : , ); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/fixtures/use-stdout.tsx ================================================ import React, {useEffect} from 'react'; import {render, useStdout, Text} from '../../src/index.js'; function WriteToStdout() { const {write} = useStdout(); useEffect(() => { write('Hello from Ink to stdout\n'); }, []); return Hello World; } const app = render(); await app.waitUntilExit(); console.log('exited'); ================================================ FILE: test/flex-align-content.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text, render} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; import createStdout from './helpers/create-stdout.js'; const renderWithAlignContent = ( alignContent: NonNullable['alignContent']>, ): string => renderToString( A B C D , ); for (const [alignContent, expectedOutput] of [ ['flex-start', 'AB\nCD\n\n\n\n'], ['center', '\n\nAB\nCD\n\n'], ['flex-end', '\n\n\n\nAB\nCD'], ['space-between', 'AB\n\n\n\n\nCD'], ['space-around', '\nAB\n\n\nCD\n'], ['space-evenly', '\nAB\n\nCD\n\n'], ['stretch', 'AB\n\n\nCD\n\n'], ] as const) { test(`align content ${alignContent}`, t => { const output = renderWithAlignContent(alignContent); t.is(output, expectedOutput); }); } test('align content defaults to flex-start', t => { const output = renderToString( A B C D , ); t.is(output, 'AB\nCD\n\n\n\n'); }); test('align content does not add extra spacing when there is no free cross-axis space', t => { const output = renderToString( A B C D , ); t.is(output, 'AB\nCD'); }); test('clears alignContent on rerender to default flex-start', t => { const stdout = createStdout(); function Test({ alignContent, }: { readonly alignContent?: React.ComponentProps['alignContent']; }) { return ( A B C D ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], '\n\nAB\nCD\n\n'); rerender(); t.is(stdout.write.lastCall.args[0], 'AB\nCD\n\n\n\n'); }); test('clears alignContent from stretch on rerender to default flex-start', t => { const stdout = createStdout(); function Test({ alignContent, }: { readonly alignContent?: React.ComponentProps['alignContent']; }) { return ( A B C D ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], 'AB\n\n\nCD\n\n'); rerender(); t.is(stdout.write.lastCall.args[0], 'AB\nCD\n\n\n\n'); }); test('clears alignContent when prop is omitted on rerender', t => { const stdout = createStdout(); function Test({showAlignContent}: {readonly showAlignContent: boolean}) { return ( A B C D ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], '\n\nAB\nCD\n\n'); rerender(); t.is(stdout.write.lastCall.args[0], 'AB\nCD\n\n\n\n'); }); test('align content center - concurrent', async t => { const output = await renderToStringAsync( A B C D , ); t.is(output, '\n\nAB\nCD\n\n'); }); ================================================ FILE: test/flex-align-items.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text, Newline} from '../src/index.js'; import {renderToString} from './helpers/render-to-string.js'; test('row - align text to center', t => { const output = renderToString( Test , ); t.is(output, '\nTest\n'); }); test('row - align multiple text nodes to center', t => { const output = renderToString( A B , ); t.is(output, '\nAB\n'); }); test('row - align text to bottom', t => { const output = renderToString( Test , ); t.is(output, '\n\nTest'); }); test('row - align multiple text nodes to bottom', t => { const output = renderToString( A B , ); t.is(output, '\n\nAB'); }); test('column - align text to center', t => { const output = renderToString( Test , ); t.is(output, ' Test'); }); test('column - align text to right', t => { const output = renderToString( Test , ); t.is(output, ' Test'); }); test('row - align items stretch', t => { const output = renderToString( X , ); t.is(output, '┌─┐\n│X│\n│ │\n│ │\n└─┘'); }); test('row - default align items stretches children', t => { const output = renderToString( X , ); t.is(output, '┌─┐\n│X│\n│ │\n│ │\n└─┘'); }); test('row - align text to baseline', t => { const output = renderToString( A B X , ); t.is(output, 'A\nBX\n'); }); ================================================ FILE: test/flex-align-self.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text, Newline} from '../src/index.js'; import {renderToString} from './helpers/render-to-string.js'; test('row - align text to center', t => { const output = renderToString( Test , ); t.is(output, '\nTest\n'); }); test('row - align multiple text nodes to center', t => { const output = renderToString( A B , ); t.is(output, '\nAB\n'); }); test('row - align text to bottom', t => { const output = renderToString( Test , ); t.is(output, '\n\nTest'); }); test('row - align multiple text nodes to bottom', t => { const output = renderToString( A B , ); t.is(output, '\n\nAB'); }); test('column - align text to center', t => { const output = renderToString( Test , ); t.is(output, ' Test'); }); test('column - align text to right', t => { const output = renderToString( Test , ); t.is(output, ' Test'); }); test('column - align self stretch', t => { const output = renderToString( X , ); t.is(output, '┌─────┐\n│X │\n└─────┘'); }); test('row - align self stretch', t => { const output = renderToString( X , ); t.is(output, '┌─┐\n│X│\n│ │\n│ │\n└─┘'); }); test('row - align self baseline', t => { const output = renderToString( A B X , ); t.is(output, 'AX\nB\n'); }); ================================================ FILE: test/flex-direction.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; test('direction row', t => { const output = renderToString( A B , ); t.is(output, 'AB'); }); test('direction row reverse', t => { const output = renderToString( A B , ); t.is(output, ' BA'); }); test('direction column', t => { const output = renderToString( A B , ); t.is(output, 'A\nB'); }); test('direction column reverse', t => { const output = renderToString( A B , ); t.is(output, '\n\nB\nA'); }); test('don’t squash text nodes when column direction is applied', t => { const output = renderToString( A B , ); t.is(output, 'A\nB'); }); // Concurrent mode tests test('direction row - concurrent', async t => { const output = await renderToStringAsync( A B , ); t.is(output, 'AB'); }); test('direction column - concurrent', async t => { const output = await renderToStringAsync( A B , ); t.is(output, 'A\nB'); }); ================================================ FILE: test/flex-justify-content.tsx ================================================ import React from 'react'; import test from 'ava'; import chalk from 'chalk'; import {Box, Text} from '../src/index.js'; import {renderToString} from './helpers/render-to-string.js'; test('row - align text to center', t => { const output = renderToString( Test , ); t.is(output, ' Test'); }); test('row - align multiple text nodes to center', t => { const output = renderToString( A B , ); t.is(output, ' AB'); }); test('row - align text to right', t => { const output = renderToString( Test , ); t.is(output, ' Test'); }); test('row - align multiple text nodes to right', t => { const output = renderToString( A B , ); t.is(output, ' AB'); }); test('row - align two text nodes on the edges', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('row - space evenly two text nodes', t => { const output = renderToString( A B , ); t.is(output, ' A B'); }); // Yoga has a bug, where first child in a container with space-around doesn't have // the correct X coordinate and measure function is used on that child node test.failing('row - align two text nodes with equal space around them', t => { const output = renderToString( A B , ); t.is(output, ' A B'); }); test('row - align colored text node when text is squashed', t => { const output = renderToString( X , ); t.is(output, ` ${chalk.green('X')}`); }); test('column - align text to center', t => { const output = renderToString( Test , ); t.is(output, '\nTest\n'); }); test('column - align text to bottom', t => { const output = renderToString( Test , ); t.is(output, '\n\nTest'); }); test('column - align two text nodes on the edges', t => { const output = renderToString( A B , ); t.is(output, 'A\n\n\nB'); }); // Yoga has a bug, where first child in a container with space-around doesn't have // the correct X coordinate and measure function is used on that child node test.failing( 'column - align two text nodes with equal space around them', t => { const output = renderToString( A B , ); t.is(output, '\nA\n\nB\n'); }, ); ================================================ FILE: test/flex-wrap.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text} from '../src/index.js'; import {renderToString} from './helpers/render-to-string.js'; test('row - no wrap', t => { const output = renderToString( A BC , ); t.is(output, 'BC\n'); }); test('column - no wrap', t => { const output = renderToString( A B C , ); t.is(output, 'B\nC'); }); test('row - wrap content', t => { const output = renderToString( A BC , ); t.is(output, 'A\nBC'); }); test('column - wrap content', t => { const output = renderToString( A B C , ); t.is(output, 'AC\nB'); }); test('column - wrap content reverse', t => { const output = renderToString( A B C , ); t.is(output, ' CA\n B'); }); test('row - wrap content reverse', t => { const output = renderToString( A B C , ); t.is(output, '\nC\nAB'); }); ================================================ FILE: test/flex.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text} from '../src/index.js'; import {renderToString} from './helpers/render-to-string.js'; test('grow equally', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('grow one element', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('do not shrink', t => { const output = renderToString( A B C , ); t.is(output, 'A B C'); }); test('shrink equally', t => { const output = renderToString( A B C , ); t.is(output, 'A B C'); }); test('set flex basis with flexDirection="row" container', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('set flex basis in percent with flexDirection="row" container', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('set flex basis with flexDirection="column" container', t => { const output = renderToString( A B , ); t.is(output, 'A\n\n\nB\n\n'); }); test('set flex basis in percent with flexDirection="column" container', t => { const output = renderToString( A B , ); t.is(output, 'A\n\n\nB\n\n'); }); ================================================ FILE: test/focus.tsx ================================================ import EventEmitter from 'node:events'; import React, {useEffect} from 'react'; import delay from 'delay'; import test from 'ava'; import {spy, stub} from 'sinon'; import {render, Box, Text, useFocus, useFocusManager} from '../src/index.js'; import createStdout from './helpers/create-stdout.js'; const createStdin = () => { const stdin = new EventEmitter() as unknown as NodeJS.WriteStream; stdin.isTTY = true; stdin.setRawMode = spy(); stdin.setEncoding = () => {}; stdin.read = stub(); stdin.unref = () => {}; stdin.ref = () => {}; return stdin; }; const emitReadable = (stdin: NodeJS.WriteStream, chunk: string) => { /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ const read = stdin.read as ReturnType; read.onCall(0).returns(chunk); read.onCall(1).returns(null); stdin.emit('readable'); read.reset(); /* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ }; type TestProps = { readonly showFirst?: boolean; readonly disableFirst?: boolean; readonly disableSecond?: boolean; readonly disableThird?: boolean; readonly autoFocus?: boolean; readonly disabled?: boolean; readonly focusNext?: boolean; readonly focusPrevious?: boolean; readonly unmountChildren?: boolean; }; function Test({ showFirst = true, disableFirst = false, disableSecond = false, disableThird = false, autoFocus = false, disabled = false, focusNext = false, focusPrevious = false, unmountChildren = false, }: TestProps) { const focusManager = useFocusManager(); useEffect(() => { if (disabled) { focusManager.disableFocus(); } else { focusManager.enableFocus(); } }, [disabled]); useEffect(() => { if (focusNext) { focusManager.focusNext(); } }, [focusNext]); useEffect(() => { if (focusPrevious) { focusManager.focusPrevious(); } }, [focusPrevious]); if (unmountChildren) { return null; } return ( {showFirst ? ( ) : null} ); } type ItemProps = { readonly label: string; readonly autoFocus: boolean; readonly disabled?: boolean; }; function Item({label, autoFocus, disabled = false}: ItemProps) { const {isFocused} = useFocus({ autoFocus, isActive: !disabled, }); return ( {label} {isFocused ? '✔' : null} ); } test('do not focus on register when auto focus is off', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second', 'Third'].join('\n'), ); }); test('focus the first component to register', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First ✔', 'Second', 'Third'].join('\n'), ); }); test('unfocus active component on Esc', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\u001B'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second', 'Third'].join('\n'), ); }); test('switch focus to first component on Tab', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First ✔', 'Second', 'Third'].join('\n'), ); }); test('switch focus to the next component on Tab', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\t'); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second ✔', 'Third'].join('\n'), ); }); test('switch focus to the first component if currently focused component is the last one on Tab', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\t'); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second', 'Third ✔'].join('\n'), ); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First ✔', 'Second', 'Third'].join('\n'), ); }); test('skip disabled component on Tab', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second', 'Third ✔'].join('\n'), ); }); test('switch focus to the previous component on Shift+Tab', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second ✔', 'Third'].join('\n'), ); emitReadable(stdin, '\u001B[Z'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First ✔', 'Second', 'Third'].join('\n'), ); }); test('switch focus to the last component if currently focused component is the first one on Shift+Tab', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\u001B[Z'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second', 'Third ✔'].join('\n'), ); }); test('skip disabled component on Shift+Tab', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\u001B[Z'); emitReadable(stdin, '\u001B[Z'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First ✔', 'Second', 'Third'].join('\n'), ); }); test('reset focus when focused component unregisters', async t => { const stdout = createStdout(); const stdin = createStdin(); const {rerender} = render(, { stdout, stdin, debug: true, }); await delay(50); rerender(); await delay(50); t.is((stdout.write as any).lastCall.args[0], ['Second', 'Third'].join('\n')); }); test('focus first component after focused component unregisters', async t => { const stdout = createStdout(); const stdin = createStdin(); const {rerender} = render(, { stdout, stdin, debug: true, }); await delay(50); rerender(); await delay(50); t.is((stdout.write as any).lastCall.args[0], ['Second', 'Third'].join('\n')); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['Second ✔', 'Third'].join('\n'), ); }); test('toggle focus management', async t => { const stdout = createStdout(); const stdin = createStdin(); const {rerender} = render(, { stdout, stdin, debug: true, }); await delay(50); rerender(); await delay(50); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First ✔', 'Second', 'Third'].join('\n'), ); rerender(); await delay(50); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second ✔', 'Third'].join('\n'), ); }); test('manually focus next component', async t => { const stdout = createStdout(); const stdin = createStdin(); const {rerender} = render(, { stdout, stdin, debug: true, }); await delay(50); rerender(); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second ✔', 'Third'].join('\n'), ); }); test('manually focus previous component', async t => { const stdout = createStdout(); const stdin = createStdin(); const {rerender} = render(, { stdout, stdin, debug: true, }); await delay(50); rerender(); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second', 'Third ✔'].join('\n'), ); }); test('does not crash when focusing next on unmounted children', async t => { const stdout = createStdout(); const stdin = createStdin(); const {rerender} = render(, { stdout, stdin, debug: true, }); await delay(50); rerender(); await delay(50); t.is((stdout.write as any).lastCall.args[0], ''); }); test('does not crash when focusing previous on unmounted children', async t => { const stdout = createStdout(); const stdin = createStdin(); const {rerender} = render(, { stdout, stdin, debug: true, }); await delay(50); rerender(); await delay(50); t.is((stdout.write as any).lastCall.args[0], ''); }); test('focuses first non-disabled component', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second', 'Third ✔'].join('\n'), ); }); test('skips disabled elements when wrapping around', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\t'); await delay(50); emitReadable(stdin, '\t'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second ✔', 'Third'].join('\n'), ); }); test('skips disabled elements when wrapping around from the front', async t => { const stdout = createStdout(); const stdin = createStdin(); render(, { stdout, stdin, debug: true, }); await delay(50); emitReadable(stdin, '\u001B[Z'); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second ✔', 'Third'].join('\n'), ); }); // Concurrent mode tests // Note: Focus tests with stdin interaction are complex to migrate. // These tests verify basic concurrent rendering with focus components. test('focus component renders in concurrent mode', async t => { const stdout = createStdout(); const stdin = createStdin(); const {act} = await import('react'); await act(async () => { render(, { stdout, stdin, debug: true, concurrent: true, }); }); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First', 'Second', 'Third'].join('\n'), ); }); test('focus component with autoFocus renders in concurrent mode', async t => { const stdout = createStdout(); const stdin = createStdin(); const {act} = await import('react'); await act(async () => { render(, { stdout, stdin, debug: true, concurrent: true, }); }); await delay(50); t.is( (stdout.write as any).lastCall.args[0], ['First ✔', 'Second', 'Third'].join('\n'), ); }); function ItemWithId({ label, id, autoFocus = false, }: { readonly label: string; readonly id: string; readonly autoFocus?: boolean; }) { const {isFocused} = useFocus({id, autoFocus}); return ( {label} {isFocused ? '✔' : null} ); } function ActiveIdReader({ onActiveId, }: { readonly onActiveId: (id: string | undefined) => void; }) { const {activeId} = useFocusManager(); onActiveId(activeId); return null; } test('activeId from useFocusManager reflects currently focused component', async t => { const stdout = createStdout(); const stdin = createStdin(); let capturedActiveId: string | undefined; render( { capturedActiveId = id; }} /> , {stdout, stdin, debug: true}, ); await delay(50); t.is(capturedActiveId, undefined); emitReadable(stdin, '\t'); await delay(50); t.is(capturedActiveId, 'first'); emitReadable(stdin, '\t'); await delay(50); t.is(capturedActiveId, 'second'); }); test('activeId resets to undefined on Esc', async t => { const stdout = createStdout(); const stdin = createStdin(); let capturedActiveId: string | undefined; render( { capturedActiveId = id; }} /> , {stdout, stdin, debug: true}, ); await delay(50); emitReadable(stdin, '\t'); await delay(50); t.is(capturedActiveId, 'first'); emitReadable(stdin, '\u001B'); await delay(50); t.is(capturedActiveId, undefined); }); test('activeId is set immediately when component uses autoFocus', async t => { const stdout = createStdout(); const stdin = createStdin(); let capturedActiveId: string | undefined; render( { capturedActiveId = id; }} /> , {stdout, stdin, debug: true}, ); await delay(50); t.is(capturedActiveId, 'first'); }); test('activeId updates when focus is changed programmatically', async t => { const stdout = createStdout(); const stdin = createStdin(); let capturedActiveId: string | undefined; let capturedFocus: ((id: string) => void) | undefined; function FocusCapture() { const {focus} = useFocusManager(); capturedFocus = focus; return null; } render( { capturedActiveId = id; }} /> , {stdout, stdin, debug: true}, ); await delay(50); t.is(capturedActiveId, undefined); capturedFocus!('second'); await delay(50); t.is(capturedActiveId, 'second'); capturedFocus!('first'); await delay(50); t.is(capturedActiveId, 'first'); }); test('activeId resets to undefined when focused component unmounts', async t => { const stdout = createStdout(); const stdin = createStdin(); let capturedActiveId: string | undefined; const {rerender} = render( { capturedActiveId = id; }} /> , {stdout, stdin, debug: true}, ); await delay(50); t.is(capturedActiveId, 'first'); rerender( { capturedActiveId = id; }} /> , ); await delay(50); t.is(capturedActiveId, undefined); }); ================================================ FILE: test/gap.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; test('gap', t => { const output = renderToString( A B C , ); t.is(output, 'A B\n\nC'); }); test('column gap', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('row gap', t => { const output = renderToString( A B , ); t.is(output, 'A\n\nB'); }); // Concurrent mode tests test('gap - concurrent', async t => { const output = await renderToStringAsync( A B C , ); t.is(output, 'A B\n\nC'); }); test('column gap - concurrent', async t => { const output = await renderToStringAsync( A B , ); t.is(output, 'A B'); }); test('row gap - concurrent', async t => { const output = await renderToStringAsync( A B , ); t.is(output, 'A\n\nB'); }); ================================================ FILE: test/helpers/create-stdin.ts ================================================ import EventEmitter from 'node:events'; import {stub} from 'sinon'; export const createStdin = (): NodeJS.WriteStream => { const stdin = new EventEmitter() as unknown as NodeJS.WriteStream; stdin.isTTY = true; stdin.setRawMode = stub(); stdin.setEncoding = () => {}; stdin.read = stub(); stdin.unref = () => {}; stdin.ref = () => {}; return stdin; }; export const emitReadable = ( stdin: NodeJS.WriteStream, chunk: string, ): void => { /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ const read = stdin.read as ReturnType; read.onCall(0).returns(chunk); read.onCall(1).returns(null); stdin.emit('readable'); read.reset(); /* eslint-enable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ }; ================================================ FILE: test/helpers/create-stdout.ts ================================================ import EventEmitter from 'node:events'; import {spy} from 'sinon'; // Fake process.stdout export type FakeStdout = { get: () => string; getWrites: () => string[]; } & NodeJS.WriteStream; const createStdout = (columns?: number, isTTY?: boolean): FakeStdout => { const stdout = new EventEmitter() as unknown as FakeStdout; stdout.columns = columns ?? 100; stdout.isTTY = isTTY ?? true; const write = spy(); stdout.write = write; stdout.get = () => write.lastCall.args[0] as string; stdout.getWrites = () => (write.args as string[][]).map(args => args[0]!); return stdout; }; export default createStdout; ================================================ FILE: test/helpers/force-colors.ts ================================================ import chalk, {supportsColor} from 'chalk'; // Force chalk to output colors even in non-TTY environments for testing export const enableTestColors = () => { // Force chalk to output colors chalk.level = 3; // Full color support (16m colors) }; export const disableTestColors = () => { // Restore chalk's automatic detection chalk.level = supportsColor ? supportsColor.level : 0; }; ================================================ FILE: test/helpers/render-to-string.ts ================================================ import {act} from 'react'; import {render} from '../../src/index.js'; import createStdout from './create-stdout.js'; type RenderToStringOptions = { columns?: number; isScreenReaderEnabled?: boolean; }; /** Synchronous render to string (legacy mode). */ export const renderToString: ( node: React.JSX.Element, options?: RenderToStringOptions, ) => string = (node, options) => { const stdout = createStdout(options?.columns ?? 100); render(node, { stdout, debug: true, isScreenReaderEnabled: options?.isScreenReaderEnabled, }); const output = stdout.get(); return output; }; /** Async render to string with concurrent mode support. Uses `act()` to properly flush updates. */ export const renderToStringAsync: ( node: React.JSX.Element, options?: RenderToStringOptions, ) => Promise = async (node, options) => { const stdout = createStdout(options?.columns ?? 100); await act(async () => { render(node, { stdout, debug: true, isScreenReaderEnabled: options?.isScreenReaderEnabled, concurrent: true, }); }); const output = stdout.get(); return output; }; ================================================ FILE: test/helpers/run.ts ================================================ import process from 'node:process'; import {createRequire} from 'node:module'; import path from 'node:path'; import url from 'node:url'; const require = createRequire(import.meta.url); // eslint-disable-next-line @typescript-eslint/consistent-type-imports const {spawn} = require('node-pty') as typeof import('node-pty'); const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); type Run = ( fixture: string, props?: {env?: Record; columns?: number}, ) => Promise; export const run: Run = async (fixture, props) => { const env: Record = { ...(process.env as Record), // eslint-disable-next-line @typescript-eslint/naming-convention CI: 'false', ...props?.env, // eslint-disable-next-line @typescript-eslint/naming-convention NODE_NO_WARNINGS: '1', }; return new Promise((resolve, reject) => { const term = spawn( 'node', ['--import=tsx', path.join(__dirname, `/../fixtures/${fixture}.tsx`)], { name: 'xterm-color', cols: typeof props?.columns === 'number' ? props.columns : 100, cwd: __dirname, env, }, ); let output = ''; term.onData(data => { output += data; }); term.onExit(({exitCode}) => { if (exitCode === 0) { resolve(output); return; } reject(new Error(`Process exited with a non-zero code: ${exitCode}`)); }); }); }; ================================================ FILE: test/helpers/term.ts ================================================ import process from 'node:process'; import {createRequire} from 'node:module'; import path from 'node:path'; import url from 'node:url'; const require = createRequire(import.meta.url); // eslint-disable-next-line @typescript-eslint/consistent-type-imports const {spawn} = require('node-pty') as typeof import('node-pty'); const fixturesDir = url.fileURLToPath(new URL('../fixtures', import.meta.url)); const term = (fixture: string, args: string[] = []) => { let resolve: (value?: any) => void; let reject: (error?: Error) => void; // eslint-disable-next-line promise/param-names const exitPromise = new Promise((resolve2, reject2) => { resolve = resolve2; reject = reject2; }); let readyResolve: () => void; // eslint-disable-next-line promise/param-names const readyPromise = new Promise(r => { readyResolve = r; }); const env: Record = { ...(process.env as Record), // eslint-disable-next-line @typescript-eslint/naming-convention NODE_NO_WARNINGS: '1', // eslint-disable-next-line @typescript-eslint/naming-convention CI: 'false', }; const ps = spawn( 'node', ['--import=tsx', path.join(fixturesDir, `${fixture}.tsx`), ...args], { name: 'xterm-color', cols: 100, cwd: fixturesDir, env, }, ); const result = { write(input: string) { // Wait for the fixture to signal it's ready to accept input void readyPromise.then(() => { ps.write(input); }); }, output: '', waitForExit: async () => exitPromise, }; ps.onData(data => { result.output += data; if (result.output.includes('__READY__')) { readyResolve(); } }); ps.onExit(({exitCode}) => { if (exitCode === 0) { resolve(); return; } reject(new Error(`Process exited with non-zero exit code: ${exitCode}`)); }); return result; }; export default term; ================================================ FILE: test/helpers/test-renderer.ts ================================================ import {act} from 'react'; import {render, type Instance} from '../../src/index.js'; import createStdout from './create-stdout.js'; type TestRenderOptions = { columns?: number; isScreenReaderEnabled?: boolean; }; export type TestInstance = Instance & { stdout: ReturnType; getOutput: () => string; rerenderAsync: (node: React.ReactNode) => Promise; }; /** Render helper that supports concurrent mode with `act()` wrapping. Uses `act()` to properly flush updates in concurrent mode. */ export async function renderAsync( node: React.ReactNode, options: TestRenderOptions = {}, ): Promise { const stdout = createStdout(options.columns ?? 100); let instance!: Instance; await act(async () => { instance = render(node, { stdout, debug: true, concurrent: true, isScreenReaderEnabled: options.isScreenReaderEnabled, }); }); return { ...instance, stdout, getOutput: () => stdout.get(), async rerenderAsync(newNode: React.ReactNode) { await act(async () => { instance.rerender(newNode); }); }, }; } /** Synchronous render for legacy mode tests (backward compatible). */ export function renderSync( node: React.ReactNode, options: TestRenderOptions = {}, ): TestInstance { const stdout = createStdout(options.columns ?? 100); const instance = render(node, { stdout, debug: true, concurrent: false, isScreenReaderEnabled: options.isScreenReaderEnabled, }); return { ...instance, stdout, getOutput: () => stdout.get(), async rerenderAsync(newNode: React.ReactNode) { instance.rerender(newNode); }, }; } /** Wrapper to make existing sync code work with concurrent mode. Use this to gradually migrate tests. */ export async function withAct(fn: () => T | Promise): Promise { let result!: T; await act(async () => { result = await fn(); }); return result; } /** Wait for pending suspense boundaries to resolve. */ export async function waitForSuspense(ms = 0): Promise { await act(async () => { await new Promise(resolve => { setTimeout(resolve, ms); }); }); } ================================================ FILE: test/hooks-use-input-kitty.tsx ================================================ import test from 'ava'; import term from './helpers/term.js'; test.serial('useInput - handle kitty protocol super modifier', async t => { const ps = term('use-input-kitty', ['super']); // 's' with super modifier (modifier 9 = super(8) + 1) ps.write('\u001B[115;9u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle kitty protocol hyper modifier', async t => { const ps = term('use-input-kitty', ['hyper']); // 'h' with hyper modifier (modifier 17 = hyper(16) + 1) ps.write('\u001B[104;17u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle kitty protocol capsLock', async t => { const ps = term('use-input-kitty', ['capsLock']); // 'a' with capsLock (modifier 65 = capsLock(64) + 1) ps.write('\u001B[97;65u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle kitty protocol numLock', async t => { const ps = term('use-input-kitty', ['numLock']); // 'a' with numLock (modifier 129 = numLock(128) + 1) ps.write('\u001B[97;129u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle kitty protocol super+ctrl', async t => { const ps = term('use-input-kitty', ['superCtrl']); // 's' with super+ctrl (modifier 13 = super(8) + ctrl(4) + 1) ps.write('\u001B[115;13u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle kitty protocol press event', async t => { const ps = term('use-input-kitty', ['press']); // 'a' press event (eventType 1 = press) ps.write('\u001B[97;1:1u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle kitty protocol repeat event', async t => { const ps = term('use-input-kitty', ['repeat']); // 'a' repeat event (eventType 2 = repeat) ps.write('\u001B[97;1:2u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle kitty protocol release event', async t => { const ps = term('use-input-kitty', ['release']); // 'a' release event (eventType 3 = release) ps.write('\u001B[97;1:3u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle kitty protocol escape key', async t => { const ps = term('use-input-kitty', ['escapeKitty']); // Escape key ps.write('\u001B[27u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial( 'useInput - non-printable kitty key (capslock) produces empty input', async t => { const ps = term('use-input-kitty', ['nonPrintable']); // Capslock (codepoint 57358) ps.write('\u001B[57358u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial( 'useInput - non-printable kitty key (f13) produces empty input', async t => { const ps = term('use-input-kitty', ['nonPrintable']); // F13 (codepoint 57376) ps.write('\u001B[57376u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial( 'useInput - non-printable kitty key (printscreen) produces empty input', async t => { const ps = term('use-input-kitty', ['nonPrintable']); // PrintScreen (codepoint 57361) ps.write('\u001B[57361u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial( 'useInput - kitty protocol space key produces space input', async t => { const ps = term('use-input-kitty', ['space']); // Space key (codepoint 32) ps.write('\u001B[32u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial( 'useInput - kitty protocol return key produces carriage return input', async t => { const ps = term('use-input-kitty', ['returnKey']); // Return key (codepoint 13) ps.write('\u001B[13u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial( 'useInput - kitty protocol ctrl+letter via codepoint 1-26 produces input', async t => { const ps = term('use-input-kitty', ['ctrlLetter']); // Ctrl+a via codepoint 1 form (modifier 5 = ctrl(4) + 1) ps.write('\u001B[1;5u'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); ================================================ FILE: test/hooks-use-input-navigation.tsx ================================================ import test from 'ava'; import term from './helpers/term.js'; test.serial('useInput - handle up arrow', async t => { const ps = term('use-input', ['upArrow']); ps.write('\u001B[A'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle down arrow', async t => { const ps = term('use-input', ['downArrow']); ps.write('\u001B[B'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle left arrow', async t => { const ps = term('use-input', ['leftArrow']); ps.write('\u001B[D'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle right arrow', async t => { const ps = term('use-input', ['rightArrow']); ps.write('\u001B[C'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial( 'useInput - handles rapid arrows and enter in one chunk', async t => { const ps = term('use-input', ['rapidArrowsEnter']); ps.write('\u001B[B\u001B[B\u001B[B\r'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial('useInput - handle meta + up arrow', async t => { const ps = term('use-input', ['upArrowMeta']); ps.write('\u001B\u001B[A'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle meta + down arrow', async t => { const ps = term('use-input', ['downArrowMeta']); ps.write('\u001B\u001B[B'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle meta + left arrow', async t => { const ps = term('use-input', ['leftArrowMeta']); ps.write('\u001B\u001B[D'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle meta + right arrow', async t => { const ps = term('use-input', ['rightArrowMeta']); ps.write('\u001B\u001B[C'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle ctrl + up arrow', async t => { const ps = term('use-input', ['upArrowCtrl']); ps.write('\u001B[1;5A'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle ctrl + down arrow', async t => { const ps = term('use-input', ['downArrowCtrl']); ps.write('\u001B[1;5B'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle ctrl + left arrow', async t => { const ps = term('use-input', ['leftArrowCtrl']); ps.write('\u001B[1;5D'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle ctrl + right arrow', async t => { const ps = term('use-input', ['rightArrowCtrl']); ps.write('\u001B[1;5C'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle page down', async t => { const ps = term('use-input', ['pageDown']); ps.write('\u001B[6~'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle page up', async t => { const ps = term('use-input', ['pageUp']); ps.write('\u001B[5~'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle home', async t => { const ps = term('use-input', ['home']); ps.write('\u001B[H'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle end', async t => { const ps = term('use-input', ['end']); ps.write('\u001B[F'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); ================================================ FILE: test/hooks-use-input.tsx ================================================ import test from 'ava'; import term from './helpers/term.js'; test.serial( 'useInput - discrete priority keeps states in sync with useTransition during rapid input', async t => { const ps = term('use-input-discrete-priority'); // Simulate rapid delete key repeat at ~30ms intervals. // State starts pre-populated with "abcde". Send 5 rapid deletes // to clear it, then wait for transitions to settle and check state. const delay = async (ms: number) => new Promise(resolve => { setTimeout(resolve, ms); }); const pressDeleteKey = () => { ps.write('\u001B[3~'); }; // Use escape sequence for delete key (raw \x7F gets processed by pty) for (const delayMilliseconds of [0, 30, 60, 90, 120]) { setTimeout(() => { pressDeleteKey(); }, delayMilliseconds); } await delay(200); // Wait for all transitions to settle, then press Enter to report state await delay(2000); ps.write('\r'); await ps.waitForExit(); const finalMatch = /FINAL .+/.exec(ps.output); t.log('Output:', finalMatch?.[0] ?? ps.output.slice(-300)); t.true(ps.output.includes('FINAL query:"" deferred:""')); }, ); test.serial('useInput - handle lowercase character', async t => { const ps = term('use-input', ['lowercase']); ps.write('q'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle uppercase character', async t => { const ps = term('use-input', ['uppercase']); ps.write('Q'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial( 'useInput - \\r should not count as an uppercase character', async t => { const ps = term('use-input', ['uppercase']); ps.write('\r'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial('useInput - pasted carriage return', async t => { const ps = term('use-input', ['pastedCarriageReturn']); ps.write('\rtest'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - pasted tab', async t => { const ps = term('use-input', ['pastedTab']); ps.write('\ttest'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial( 'useInput - receives bracketed paste when no usePaste handler is active', async t => { const ps = term('use-input', ['bracketedPaste']); ps.write('\u001B[200~hello\u001B[201~'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial('useInput - handle escape', async t => { const ps = term('use-input', ['escape']); ps.write('\u001B'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle ctrl', async t => { const ps = term('use-input', ['ctrl']); ps.write('\u0006'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle meta', async t => { const ps = term('use-input', ['meta']); ps.write('\u001Bm'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - flushes ESC[ prefix as literal input', async t => { const ps = term('use-input', ['escapeBracketPrefix']); ps.write('\u001B['); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle meta + O with pending flush', async t => { const ps = term('use-input', ['metaUpperO']); ps.write('\u001BO'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle tab', async t => { const ps = term('use-input', ['tab']); ps.write('\t'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle shift + tab', async t => { const ps = term('use-input', ['shiftTab']); ps.write('\u001B[Z'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle backspace', async t => { const ps = term('use-input', ['backspace']); ps.write('\u0008'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle delete', async t => { const ps = term('use-input', ['delete']); ps.write('\u007F'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle remove (delete)', async t => { const ps = term('use-input', ['remove']); ps.write('\u001B[3~'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); test.serial('useInput - handle option + return (macOS)', async t => { const ps = term('use-input', ['returnMeta']); ps.write('\u001B\r'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }); ================================================ FILE: test/hooks-use-paste.tsx ================================================ import test from 'ava'; import term from './helpers/term.js'; test.serial( 'usePaste - receives bracketed paste as single text blob', async t => { const ps = term('use-paste', ['basic']); ps.write('\u001B[200~hello world\u001B[201~'); await ps.waitForExit(); t.true(ps.output.includes('exited')); t.true( ps.output.includes('\u001B[?2004h'), 'bracketed paste mode was enabled', ); t.true( ps.output.includes('\u001B[?2004l'), 'bracketed paste mode was disabled on exit', ); }, ); test.serial( 'usePaste - paste content with escape sequences is delivered verbatim', async t => { const ps = term('use-paste', ['escapeSequences']); ps.write('\u001B[200~hello\u001B[Aworld\u001B[201~'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial( 'usePaste - useInput does not receive bracketed paste content', async t => { const ps = term('use-paste', ['noUseInput']); ps.write('\u001B[200~hello\u001B[201~'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); test.serial( 'usePaste - multiple simultaneous hooks both receive the same paste event', async t => { const ps = term('use-paste', ['multipleHooks']); ps.write('\u001B[200~hello\u001B[201~'); await ps.waitForExit(); t.true(ps.output.includes('exited')); }, ); ================================================ FILE: test/hooks.tsx ================================================ import test, {type ExecutionContext} from 'ava'; import stripAnsi from 'strip-ansi'; import term from './helpers/term.js'; test.serial('useInput - ignore input if not active', async t => { const ps = term('use-input-multiple'); ps.write('x'); await ps.waitForExit(); t.false(ps.output.includes('xx')); t.true(ps.output.includes('x')); t.true(ps.output.includes('exited')); }); // For some reason this test is flaky, so we have to resort to using `t.try` to run it multiple times test.serial( 'useInput - handle Ctrl+C when `exitOnCtrlC` is `false`', async t => { const run = async (tt: ExecutionContext) => { const ps = term('use-input-ctrl-c'); ps.write('\u0003'); await ps.waitForExit(); tt.true(ps.output.includes('exited')); }; const firstTry = await t.try(run); if (firstTry.passed) { firstTry.commit(); return; } firstTry.discard(); const secondTry = await t.try(run); if (secondTry.passed) { secondTry.commit(); return; } secondTry.discard(); const thirdTry = await t.try(run); thirdTry.commit(); }, ); test.serial( 'useInput - no MaxListenersExceededWarning with many useInput hooks', async t => { const ps = term('use-input-many'); await ps.waitForExit(); t.false(ps.output.includes('MaxListenersExceededWarning')); t.true(ps.output.includes('exited')); }, ); test.serial( 'useInput - handle Ctrl+C via kitty codepoint-3 form when `exitOnCtrlC` is `false`', async t => { const run = async (tt: ExecutionContext) => { const ps = term('use-input-ctrl-c'); // Ctrl+C via kitty codepoint 3 form (modifier 5 = ctrl(4) + 1) ps.write('\u001B[3;5u'); await ps.waitForExit(); tt.true(ps.output.includes('exited')); }; const firstTry = await t.try(run); if (firstTry.passed) { firstTry.commit(); return; } firstTry.discard(); const secondTry = await t.try(run); if (secondTry.passed) { secondTry.commit(); return; } secondTry.discard(); const thirdTry = await t.try(run); thirdTry.commit(); }, ); test.serial('useStdout - write to stdout', async t => { const ps = term('use-stdout'); await ps.waitForExit(); const lines = stripAnsi(ps.output).split('\r\n'); t.deepEqual(lines.slice(1, -1), [ 'Hello from Ink to stdout', 'Hello World', 'exited', ]); }); // `node-pty` doesn't support streaming stderr output, so I need to figure out // how to test useStderr() hook. child_process.spawn() can't be used, because // Ink fails with "raw mode unsupported" error. test.todo('useStderr - write to stderr'); ================================================ FILE: test/input-parser.ts ================================================ import test from 'ava'; import {createInputParser, type InputEvent} from '../src/input-parser.js'; const parseChunks = (chunks: string[]): InputEvent[] => { const parser = createInputParser(); const events: InputEvent[] = []; for (const chunk of chunks) { events.push(...parser.push(chunk)); } return events; }; test('passes through plain text chunks', t => { t.deepEqual(parseChunks(['hello', ' ', 'world']), ['hello', ' ', 'world']); }); test('keeps plain text and control sequences separate', t => { t.deepEqual(parseChunks(['a\u001B[Ab']), ['a', '\u001B[A', 'b']); }); test('parses multiple standard CSI keys in one chunk', t => { t.deepEqual(parseChunks(['\u001B[A\u001B[B\u001B[C\u001B[D']), [ '\u001B[A', '\u001B[B', '\u001B[C', '\u001B[D', ]); }); test('parses CSI sequences with parameters', t => { t.deepEqual(parseChunks(['\u001B[1;5A\u001B[5~\u001B[6~']), [ '\u001B[1;5A', '\u001B[5~', '\u001B[6~', ]); }); test('parses kitty protocol sequence as one key event', t => { t.deepEqual(parseChunks(['\u001B[97;5u']), ['\u001B[97;5u']); }); test('parses SS3 sequences as one key event', t => { t.deepEqual(parseChunks(['\u001BOA\u001BOB\u001BOC\u001BOD']), [ '\u001BOA', '\u001BOB', '\u001BOC', '\u001BOD', ]); }); test('does not consume a following escape as SS3 final byte', t => { t.deepEqual(parseChunks(['\u001BO\u001B[A']), ['\u001BO', '\u001B[A']); }); test('parses meta+CSI sequence with double escape', t => { t.deepEqual(parseChunks(['\u001B\u001B[A']), ['\u001B\u001B[A']); }); test('parses escaped printable code points', t => { t.deepEqual(parseChunks(['\u001Bx\u001B1']), ['\u001Bx', '\u001B1']); }); test('parses escaped supplementary code points', t => { t.deepEqual(parseChunks(['\u001B😀']), ['\u001B😀']); }); test('preserves legacy ESC[[... sequences in a mixed chunk', t => { t.deepEqual(parseChunks(['\u001B[[A\u001B[[5~']), [ '\u001B[[A', '\u001B[[5~', ]); }); test('preserves legacy ESC[[... sequences across chunks', t => { t.deepEqual(parseChunks(['\u001B[[', 'A\u001B[[5~']), [ '\u001B[[A', '\u001B[[5~', ]); }); test('parses legacy and standard CSI sequences mixed together', t => { t.deepEqual(parseChunks(['\u001B[[A\u001B[B\u001B[[6~\u001B[1;5D']), [ '\u001B[[A', '\u001B[B', '\u001B[[6~', '\u001B[1;5D', ]); }); test('holds incomplete CSI sequence until final byte arrives', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B['), []); t.true(parser.hasPendingEscape()); t.deepEqual(parser.push('1;5'), []); t.deepEqual(parser.push('A'), ['\u001B[1;5A']); }); test('holds incomplete legacy ESC[[... sequence until final byte arrives', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B[['), []); t.deepEqual(parser.push('5'), []); t.deepEqual(parser.push('~'), ['\u001B[[5~']); }); test('holds incomplete SS3 sequence until final byte arrives', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001BO'), []); t.deepEqual(parser.push('A'), ['\u001BOA']); }); test('holds incomplete double-escape CSI sequence until final byte arrives', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B\u001B['), []); t.deepEqual(parser.push('A'), ['\u001B\u001B[A']); }); test('keeps pending plain escape and can flush it', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B'), []); t.true(parser.hasPendingEscape()); t.is(parser.flushPendingEscape(), '\u001B'); t.false(parser.hasPendingEscape()); }); test('flushes pending CSI prefix as literal input', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B['), []); t.true(parser.hasPendingEscape()); t.is(parser.flushPendingEscape(), '\u001B['); t.false(parser.hasPendingEscape()); t.deepEqual(parser.push('A'), ['A']); }); test('reset clears pending input state', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B['), []); parser.reset(); t.deepEqual(parser.push('A'), ['A']); }); test('treats invalid CSI continuation as escaped code point plus plain text', t => { t.deepEqual(parseChunks(['\u001B[\n']), ['\u001B[', '\n']); }); test('parses mixed text and many key events in one read', t => { t.deepEqual(parseChunks(['start\u001B[A mid \u001BOH end\u001B[[5~']), [ 'start', '\u001B[A', ' mid ', '\u001BOH', ' end', '\u001B[[5~', ]); }); test('flushes pending SS3 prefix as literal input', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001BO'), []); t.true(parser.hasPendingEscape()); t.is(parser.flushPendingEscape(), '\u001BO'); t.deepEqual(parser.push('x'), ['x']); }); test('flushes pending legacy CSI prefix as literal input', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B[['), []); t.true(parser.hasPendingEscape()); t.is(parser.flushPendingEscape(), '\u001B[['); t.deepEqual(parser.push('x'), ['x']); }); test('parses meta+SS3 sequence with double escape', t => { t.deepEqual(parseChunks(['\u001B\u001BOA']), ['\u001B\u001BOA']); }); test('holds incomplete double-escape SS3 sequence until final byte arrives', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B\u001BO'), []); t.true(parser.hasPendingEscape()); t.deepEqual(parser.push('A'), ['\u001B\u001BOA']); }); test('emits double escape as single event for non-control character', t => { t.deepEqual(parseChunks(['\u001B\u001Bx']), ['\u001B\u001B', 'x']); }); test('empty chunk produces no events', t => { t.deepEqual(parseChunks(['']), []); }); test('empty chunk does not disturb pending state', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B['), []); t.deepEqual(parser.push(''), []); t.true(parser.hasPendingEscape()); t.deepEqual(parser.push('A'), ['\u001B[A']); }); test('plain text followed by incomplete escape holds escape as pending', t => { const parser = createInputParser(); t.deepEqual(parser.push('hello\u001B'), ['hello']); t.true(parser.hasPendingEscape()); t.is(parser.flushPendingEscape(), '\u001B'); }); const deleteAndBackspaceCases = [ { title: 'splits batched delete characters into individual events', chunks: ['\u007F\u007F\u007F'], events: ['\u007F', '\u007F', '\u007F'], }, { title: 'splits batched backspace characters into individual events', chunks: ['\u0008\u0008\u0008'], events: ['\u0008', '\u0008', '\u0008'], }, { title: 'splits mixed delete and backspace characters', chunks: ['\u007F\u0008\u007F'], events: ['\u007F', '\u0008', '\u007F'], }, { title: 'splits mixed printable text and delete characters', chunks: ['abc\u007F\u007F\u007F'], events: ['abc', '\u007F', '\u007F', '\u007F'], }, { title: 'single delete character is preserved as individual event', chunks: ['\u007F'], events: ['\u007F'], }, { title: 'single backspace character is preserved as individual event', chunks: ['\u0008'], events: ['\u0008'], }, { title: 'splits trailing delete from text', chunks: ['abc\u007F'], events: ['abc', '\u007F'], }, { title: 'splits delete characters before escape sequences', chunks: ['\u007F\u007F\u001B[A'], events: ['\u007F', '\u007F', '\u001B[A'], }, { title: 'splits delete characters after escape sequences', chunks: ['\u001B[A\u007F\u007F'], events: ['\u001B[A', '\u007F', '\u007F'], }, { title: 'splits delete characters between escape sequences', chunks: ['\u001B[A\u007F\u001B[B'], events: ['\u001B[A', '\u007F', '\u001B[B'], }, { title: 'splits backspace characters around escape sequences', chunks: ['\u0008\u001B[A\u0008'], events: ['\u0008', '\u001B[A', '\u0008'], }, { title: 'splits interleaved text and delete characters', chunks: ['ab\u007Fcd'], events: ['ab', '\u007F', 'cd'], }, { title: 'does not split pasted carriage return from text', chunks: ['\rtest'], events: ['\rtest'], }, { title: 'does not split pasted tab from text', chunks: ['\ttest'], events: ['\ttest'], }, ] as const; for (const testCase of deleteAndBackspaceCases) { test(testCase.title, t => { t.deepEqual(parseChunks(testCase.chunks), testCase.events); }); } test('assembles CSI sequence from single-byte chunks', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B'), []); t.deepEqual(parser.push('['), []); t.deepEqual(parser.push('1'), []); t.deepEqual(parser.push(';'), []); t.deepEqual(parser.push('5'), []); t.deepEqual(parser.push('A'), ['\u001B[1;5A']); }); test('emits paste event for bracketed paste sequence', t => { t.deepEqual(parseChunks(['\u001B[200~hello world\u001B[201~']), [ {paste: 'hello world'}, ]); }); test('emits paste event for multiline bracketed paste', t => { t.deepEqual(parseChunks(['\u001B[200~line1\nline2\u001B[201~']), [ {paste: 'line1\nline2'}, ]); }); test('paste content with escape sequences is delivered verbatim', t => { t.deepEqual(parseChunks(['\u001B[200~hello\u001B[Aworld\u001B[201~']), [ {paste: 'hello\u001B[Aworld'}, ]); }); test('emits normal events before and after bracketed paste', t => { t.deepEqual(parseChunks(['before\u001B[200~pasted\u001B[201~after']), [ 'before', {paste: 'pasted'}, 'after', ]); }); test('emits multiple paste events in one chunk', t => { t.deepEqual( parseChunks(['\u001B[200~first\u001B[201~mid\u001B[200~second\u001B[201~']), [{paste: 'first'}, 'mid', {paste: 'second'}], ); }); test('holds incomplete bracketed paste as pending', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B[200~hello'), []); t.false(parser.hasPendingEscape()); t.deepEqual(parser.push(' world\u001B[201~'), [{paste: 'hello world'}]); }); test('assembles bracketed paste from chunk-by-chunk delivery', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B[200~'), []); t.deepEqual(parser.push('hello'), []); t.deepEqual(parser.push('\u001B[201~'), [{paste: 'hello'}]); }); test('emits empty paste for adjacent paste markers', t => { t.deepEqual(parseChunks(['\u001B[200~\u001B[201~']), [{paste: ''}]); }); test('handles pasteStart split before the tilde (\\u001B[200 without ~)', t => { const parser = createInputParser(); // Chunk ends exactly at the 5th byte of the 6-byte pasteStart sequence. // Keep waiting for the final `~` to avoid splitting bracketed paste input. t.deepEqual(parser.push('\u001B[200'), []); t.false(parser.hasPendingEscape()); t.deepEqual(parser.push('~hello\u001B[201~'), [{paste: 'hello'}]); }); test('hasPendingEscape returns true for length-3 pasteStart prefix (\\u001B[2)', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B[2'), []); t.true(parser.hasPendingEscape()); }); test('hasPendingEscape returns true for length-4 pasteStart prefix (\\u001B[20)', t => { const parser = createInputParser(); t.deepEqual(parser.push('\u001B[20'), []); t.true(parser.hasPendingEscape()); }); test('paste event delivers delete and backspace chars verbatim without splitting', t => { t.deepEqual(parseChunks(['\u001B[200~\u007F\u0008\u007F\u001B[201~']), [ {paste: '\u007F\u0008\u007F'}, ]); }); ================================================ FILE: test/kitty-keyboard.tsx ================================================ import process from 'node:process'; import EventEmitter from 'node:events'; import {Buffer} from 'node:buffer'; import React from 'react'; import test from 'ava'; import {stub, spy} from 'sinon'; import parseKeypress from '../src/parse-keypress.js'; import {render, Text} from '../src/index.js'; // Helper to create kitty protocol CSI u sequences const kittyKey = ( codepoint: number, modifiers?: number, eventType?: number, textCodepoints?: number[], ): string => { let seq = `\u001B[${codepoint}`; if ( modifiers !== undefined || eventType !== undefined || textCodepoints !== undefined ) { seq += `;${modifiers ?? 1}`; } if (eventType !== undefined || textCodepoints !== undefined) { seq += `:${eventType ?? 1}`; } if (textCodepoints !== undefined) { seq += `;${textCodepoints.join(':')}`; } seq += 'u'; return seq; }; test('kitty protocol - simple character', t => { // 'a' key const result = parseKeypress(kittyKey(97)); t.is(result.name, 'a'); t.false(result.ctrl); t.false(result.shift); t.false(result.meta); t.is(result.eventType, 'press'); t.true(result.isKittyProtocol); }); test('kitty protocol - uppercase character (shift)', t => { // 'A' with shift (modifier 2 = shift + 1) const result = parseKeypress(kittyKey(65, 2)); t.is(result.name, 'a'); t.true(result.shift); t.false(result.ctrl); t.is(result.eventType, 'press'); }); test('kitty protocol - ctrl modifier', t => { // 'a' with ctrl (modifier 5 = ctrl(4) + 1) const result = parseKeypress(kittyKey(97, 5)); t.is(result.name, 'a'); t.true(result.ctrl); t.false(result.shift); t.is(result.eventType, 'press'); }); test('kitty protocol - alt/option modifier', t => { // 'a' with alt (modifier 3 = alt(2) + 1) const result = parseKeypress(kittyKey(97, 3)); t.is(result.name, 'a'); t.true(result.option); t.false(result.ctrl); t.is(result.eventType, 'press'); }); test('kitty protocol - super modifier', t => { // 'a' with super (modifier 9 = super(8) + 1) const result = parseKeypress(kittyKey(97, 9)); t.is(result.name, 'a'); t.true(result.super); t.false(result.ctrl); t.is(result.eventType, 'press'); }); test('kitty protocol - hyper modifier', t => { // 'a' with hyper (modifier 17 = hyper(16) + 1) const result = parseKeypress(kittyKey(97, 17)); t.is(result.name, 'a'); t.true(result.hyper); t.false(result.super); t.is(result.eventType, 'press'); }); test('kitty protocol - meta modifier', t => { // 'a' with meta (modifier 33 = meta(32) + 1) const result = parseKeypress(kittyKey(97, 33)); t.is(result.name, 'a'); t.true(result.meta); t.is(result.eventType, 'press'); }); test('kitty protocol - caps lock', t => { // 'a' with capsLock (modifier 65 = capsLock(64) + 1) const result = parseKeypress(kittyKey(97, 65)); t.is(result.name, 'a'); t.true(result.capsLock); t.is(result.eventType, 'press'); }); test('kitty protocol - num lock', t => { // 'a' with numLock (modifier 129 = numLock(128) + 1) const result = parseKeypress(kittyKey(97, 129)); t.is(result.name, 'a'); t.true(result.numLock); t.is(result.eventType, 'press'); }); test('kitty protocol - combined modifiers (ctrl+shift)', t => { // 'a' with ctrl+shift (modifier 6 = ctrl(4) + shift(1) + 1) const result = parseKeypress(kittyKey(97, 6)); t.is(result.name, 'a'); t.true(result.ctrl); t.true(result.shift); t.false(result.meta); t.is(result.eventType, 'press'); }); test('kitty protocol - combined modifiers (super+ctrl)', t => { // 's' with super+ctrl (modifier 13 = super(8) + ctrl(4) + 1) const result = parseKeypress(kittyKey(115, 13)); t.is(result.name, 's'); t.true(result.super); t.true(result.ctrl); t.false(result.shift); t.is(result.eventType, 'press'); }); test('kitty protocol - escape key', t => { // Escape key const result = parseKeypress(kittyKey(27)); t.is(result.name, 'escape'); t.is(result.eventType, 'press'); }); test('kitty protocol - return/enter key', t => { // Return/enter key const result = parseKeypress(kittyKey(13)); t.is(result.name, 'return'); t.is(result.eventType, 'press'); }); test('kitty protocol - tab key', t => { // Tab key const result = parseKeypress(kittyKey(9)); t.is(result.name, 'tab'); t.is(result.eventType, 'press'); }); test('kitty protocol - backspace key', t => { // Backspace key const result = parseKeypress(kittyKey(8)); t.is(result.name, 'backspace'); t.is(result.eventType, 'press'); }); test('kitty protocol - delete key', t => { // Delete key const result = parseKeypress(kittyKey(127)); t.is(result.name, 'delete'); t.is(result.eventType, 'press'); }); test('kitty protocol - space key', t => { // Space key const result = parseKeypress(kittyKey(32)); t.is(result.name, 'space'); t.is(result.eventType, 'press'); }); test('kitty protocol - event type press', t => { // 'a' press event const result = parseKeypress(kittyKey(97, 1, 1)); t.is(result.name, 'a'); t.is(result.eventType, 'press'); }); test('kitty protocol - event type repeat', t => { // 'a' repeat event const result = parseKeypress(kittyKey(97, 1, 2)); t.is(result.name, 'a'); t.is(result.eventType, 'repeat'); }); test('kitty protocol - event type release', t => { // 'a' release event const result = parseKeypress(kittyKey(97, 1, 3)); t.is(result.name, 'a'); t.is(result.eventType, 'release'); }); test('kitty protocol - number keys', t => { // '1' key const result = parseKeypress(kittyKey(49)); t.is(result.name, '1'); t.is(result.eventType, 'press'); }); test('kitty protocol - special character', t => { // '@' key const result = parseKeypress(kittyKey(64)); t.is(result.name, '@'); t.is(result.eventType, 'press'); }); test('kitty protocol - ctrl+letter produces codepoint 1-26', t => { // When using ctrl+a, kitty sends codepoint 1 (not 97) // Ctrl+a (codepoint 1, modifier 5 = ctrl + 1) const result = parseKeypress(kittyKey(1, 5)); t.is(result.name, 'a'); t.true(result.ctrl); }); test('kitty protocol - preserves sequence and raw', t => { const seq = kittyKey(97, 5); const result = parseKeypress(seq); t.is(result.sequence, seq); t.is(result.raw, seq); }); test('kitty protocol - text-as-codepoints field', t => { // 'a' key with text-as-codepoints containing 'A' (shifted) const result = parseKeypress(kittyKey(97, 2, 1, [65])); t.is(result.name, 'a'); t.is(result.text, 'A'); t.true(result.shift); t.true(result.isKittyProtocol); }); test('kitty protocol - text-as-codepoints with multiple codepoints', t => { // Key with text containing multiple codepoints (e.g., composed character) const result = parseKeypress(kittyKey(97, 1, 1, [72, 101])); t.is(result.text, 'He'); t.true(result.isKittyProtocol); }); test('kitty protocol - supplementary unicode codepoint', t => { // Emoji: 😀 (U+1F600 = 128512) const result = parseKeypress(kittyKey(128_512)); t.is(result.name, '😀'); t.true(result.isKittyProtocol); }); test('kitty protocol - text-as-codepoints with supplementary unicode', t => { // Text field with emoji codepoint const result = parseKeypress(kittyKey(97, 1, 1, [128_512])); t.is(result.text, '😀'); t.true(result.isKittyProtocol); }); test('kitty protocol - text defaults to character from codepoint', t => { const result = parseKeypress(kittyKey(97)); t.is(result.text, 'a'); t.true(result.isKittyProtocol); }); // --- Kitty-enhanced special key tests --- test('kitty protocol - arrow keys with event type', t => { // Up arrow press: CSI 1;1:1 A const up = parseKeypress('\u001B[1;1:1A'); t.is(up.name, 'up'); t.is(up.eventType, 'press'); t.true(up.isKittyProtocol); // Down arrow release: CSI 1;1:3 B const down = parseKeypress('\u001B[1;1:3B'); t.is(down.name, 'down'); t.is(down.eventType, 'release'); t.true(down.isKittyProtocol); // Right arrow repeat: CSI 1;1:2 C const right = parseKeypress('\u001B[1;1:2C'); t.is(right.name, 'right'); t.is(right.eventType, 'repeat'); t.true(right.isKittyProtocol); // Left arrow: CSI 1;1:1 D const left = parseKeypress('\u001B[1;1:1D'); t.is(left.name, 'left'); t.is(left.eventType, 'press'); t.true(left.isKittyProtocol); }); test('kitty protocol - arrow keys with modifiers', t => { // Ctrl+up: CSI 1;5:1 A (modifiers=5 means ctrl(4)+1) const result = parseKeypress('\u001B[1;5:1A'); t.is(result.name, 'up'); t.true(result.ctrl); t.is(result.eventType, 'press'); t.true(result.isKittyProtocol); }); test('kitty protocol - home and end keys', t => { const home = parseKeypress('\u001B[1;1:1H'); t.is(home.name, 'home'); t.is(home.eventType, 'press'); t.true(home.isKittyProtocol); const end = parseKeypress('\u001B[1;1:1F'); t.is(end.name, 'end'); t.is(end.eventType, 'press'); t.true(end.isKittyProtocol); }); test('kitty protocol - tilde-terminated special keys', t => { // Delete: CSI 3;1:1 ~ const del = parseKeypress('\u001B[3;1:1~'); t.is(del.name, 'delete'); t.is(del.eventType, 'press'); t.true(del.isKittyProtocol); // Insert: CSI 2;1:1 ~ const ins = parseKeypress('\u001B[2;1:1~'); t.is(ins.name, 'insert'); t.true(ins.isKittyProtocol); // Page up: CSI 5;1:1 ~ const pgup = parseKeypress('\u001B[5;1:1~'); t.is(pgup.name, 'pageup'); t.true(pgup.isKittyProtocol); // F5: CSI 15;1:1 ~ const f5 = parseKeypress('\u001B[15;1:1~'); t.is(f5.name, 'f5'); t.true(f5.isKittyProtocol); }); test('kitty protocol - tilde keys with modifiers', t => { // Shift+Delete: CSI 3;2:1 ~ (modifiers=2 means shift(1)+1) const result = parseKeypress('\u001B[3;2:1~'); t.is(result.name, 'delete'); t.true(result.shift); t.is(result.eventType, 'press'); t.true(result.isKittyProtocol); }); // --- Malformed input handling --- test('kitty protocol - invalid codepoint above U+10FFFF returns safe empty keypress', t => { // Codepoint 1114112 = 0x110000, one above max Unicode const result = parseKeypress('\u001B[1114112u'); t.is(result.name, ''); t.false(result.ctrl); t.true(result.isKittyProtocol); t.false(result.isPrintable); }); test('kitty protocol - surrogate codepoint returns safe empty keypress', t => { // Codepoint 0xD800 is a surrogate const result = parseKeypress('\u001B[55296u'); t.is(result.name, ''); t.false(result.ctrl); t.true(result.isKittyProtocol); t.false(result.isPrintable); }); test('kitty protocol - invalid text codepoint replaced with fallback', t => { // Valid primary codepoint, but text field has an invalid codepoint const result = parseKeypress(kittyKey(97, 1, 1, [1_114_112])); t.is(result.name, 'a'); t.is(result.text, '?'); t.true(result.isKittyProtocol); }); test('kitty protocol - malformed modifier 0 does not set all flags', t => { // Malformed sequence with modifier 0 (should clamp to 0, not become -1) const result = parseKeypress('\u001B[97;0u'); t.is(result.name, 'a'); t.false(result.ctrl); t.false(result.shift); t.false(result.option); t.false(result.super ?? false); t.true(result.isKittyProtocol); }); // --- Legacy fallback --- test('non-kitty sequences fall back to legacy parsing', t => { // Regular escape sequence (not kitty protocol) // Up arrow key const result = parseKeypress('\u001B[A'); t.is(result.name, 'up'); t.is(result.isKittyProtocol, undefined); }); test('non-kitty sequences - ctrl+c', t => { // Ctrl+c const result = parseKeypress('\u0003'); t.is(result.name, 'c'); t.true(result.ctrl); t.is(result.isKittyProtocol, undefined); }); // --- isPrintable field tests --- test('kitty protocol - isPrintable is true for regular characters', t => { // 'a' key const result = parseKeypress(kittyKey(97)); t.true(result.isPrintable); }); test('kitty protocol - isPrintable is true for digits', t => { // '1' key const result = parseKeypress(kittyKey(49)); t.true(result.isPrintable); }); test('kitty protocol - isPrintable is true for symbols', t => { // '@' key const result = parseKeypress(kittyKey(64)); t.true(result.isPrintable); }); test('kitty protocol - isPrintable is true for emoji', t => { const result = parseKeypress(kittyKey(128_512)); t.true(result.isPrintable); }); test('kitty protocol - isPrintable is false for escape', t => { const result = parseKeypress(kittyKey(27)); t.false(result.isPrintable); }); test('kitty protocol - isPrintable is true for return', t => { const result = parseKeypress(kittyKey(13)); t.true(result.isPrintable); }); test('kitty protocol - isPrintable is false for tab', t => { const result = parseKeypress(kittyKey(9)); t.false(result.isPrintable); }); test('kitty protocol - isPrintable is true for space', t => { const result = parseKeypress(kittyKey(32)); t.true(result.isPrintable); }); test('kitty protocol - isPrintable is false for backspace', t => { const result = parseKeypress(kittyKey(8)); t.false(result.isPrintable); }); test('kitty protocol - isPrintable is false for ctrl+letter', t => { // Ctrl+a (codepoint 1) const result = parseKeypress(kittyKey(1, 5)); t.false(result.isPrintable); }); test('kitty protocol - isPrintable is false for special keys (arrows)', t => { // Up arrow via kitty enhanced special key format const result = parseKeypress('\u001B[1;1:1A'); t.false(result.isPrintable); }); // --- Non-printable key suppression tests (feedback #3 repros) --- test('kitty protocol - capslock (57358) is non-printable', t => { // \x1b[57358u -> capslock should have isPrintable=false const result = parseKeypress('\u001B[57358u'); t.is(result.name, 'capslock'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - printscreen (57361) is non-printable', t => { // \x1b[57361u -> printscreen should have isPrintable=false const result = parseKeypress('\u001B[57361u'); t.is(result.name, 'printscreen'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - f13 (57376) is non-printable', t => { // \x1b[57376u -> f13 should have isPrintable=false const result = parseKeypress('\u001B[57376u'); t.is(result.name, 'f13'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - media key (57428 mediaplay) is non-printable', t => { const result = parseKeypress('\u001B[57428u'); t.is(result.name, 'mediaplay'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - modifier-only key (57441 leftshift) is non-printable', t => { const result = parseKeypress('\u001B[57441u'); t.is(result.name, 'leftshift'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - modifier-only key (57442 leftcontrol) is non-printable', t => { const result = parseKeypress('\u001B[57442u'); t.is(result.name, 'leftcontrol'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - kp keys (57399 kp0) are non-printable', t => { const result = parseKeypress('\u001B[57399u'); t.is(result.name, 'kp0'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - scrolllock (57359) is non-printable', t => { const result = parseKeypress('\u001B[57359u'); t.is(result.name, 'scrolllock'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - numlock (57360) is non-printable', t => { const result = parseKeypress('\u001B[57360u'); t.is(result.name, 'numlock'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - pause (57362) is non-printable', t => { const result = parseKeypress('\u001B[57362u'); t.is(result.name, 'pause'); t.false(result.isPrintable); t.true(result.isKittyProtocol); }); test('kitty protocol - volume keys are non-printable', t => { // Lower volume (57438) const lower = parseKeypress('\u001B[57438u'); t.is(lower.name, 'lowervolume'); t.false(lower.isPrintable); // Raise volume (57439) const raise = parseKeypress('\u001B[57439u'); t.is(raise.name, 'raisevolume'); t.false(raise.isPrintable); // Mute volume (57440) const mute = parseKeypress('\u001B[57440u'); t.is(mute.name, 'mutevolume'); t.false(mute.isPrintable); }); // --- Init/cleanup control sequence tests --- const createFakeStdout = () => { const stdout = new EventEmitter() as unknown as NodeJS.WriteStream; stdout.columns = 100; stdout.isTTY = true; const write = spy(); stdout.write = write; return {stdout, write}; }; const createFakeStdin = () => { const stdin = new EventEmitter() as unknown as NodeJS.ReadStream; stdin.isTTY = true; stdin.setRawMode = stub(); stdin.setEncoding = () => {}; stdin.read = stub(); return stdin; }; const getWrittenStrings = (write: ReturnType): string[] => (write.args as string[][]).map(args => args[0]!); test.serial( 'kitty protocol - writes enable sequence on init when mode is enabled', t => { const {stdout, write} = createFakeStdout(); const stdin = createFakeStdin(); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'enabled'}, }); // CSI > 1 u (push keyboard mode with disambiguateEscapeCodes flag) t.true(getWrittenStrings(write).includes('\u001B[>1u')); unmount(); }, ); test.serial('kitty protocol - writes disable sequence on unmount', t => { const {stdout, write} = createFakeStdout(); const stdin = createFakeStdin(); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'enabled'}, }); unmount(); // CSI < u (pop keyboard mode) t.true(getWrittenStrings(write).includes('\u001B[ { const {stdout, write} = createFakeStdout(); const stdin = createFakeStdin(); stdin.isTTY = false; const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'enabled'}, }); t.false(getWrittenStrings(write).includes('\u001B[>1u')); unmount(); }); test.serial('kitty protocol - not enabled when stdout is not a TTY', t => { const {stdout, write} = createFakeStdout(); stdout.isTTY = false; const stdin = createFakeStdin(); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'enabled'}, }); t.false(getWrittenStrings(write).includes('\u001B[>1u')); unmount(); }); // --- Auto-detection race condition tests --- test.serial( 'kitty protocol - auto detection does not enable protocol after unmount', t => { const {stdout, write} = createFakeStdout(); const stdin = createFakeStdin(); const origKittyId = process.env['KITTY_WINDOW_ID']; process.env['KITTY_WINDOW_ID'] = '1'; t.teardown(() => { if (origKittyId === undefined) { delete process.env['KITTY_WINDOW_ID']; } else { process.env['KITTY_WINDOW_ID'] = origKittyId; } }); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'auto'}, }); // Unmount before the terminal responds unmount(); // Simulate a late terminal response arriving after unmount stdin.emit('data', '\u001B[?1u'); // The enable sequence should NOT have been written after unmount const strings = getWrittenStrings(write); const enableCount = strings.filter(s => s === '\u001B[>1u').length; t.is(enableCount, 0); }, ); test.serial( 'kitty protocol - auto detection handles synchronous query response', t => { const {stdout} = createFakeStdout(); const stdin = createFakeStdin(); const writtenStrings: string[] = []; // Override stdout.write to synchronously emit the response on stdin // when the query sequence is written, simulating a fast terminal stdout.write = ((data: string) => { writtenStrings.push(data); if (data === '\u001B[?u') { stdin.emit('data', '\u001B[?1u'); } return true; }) as typeof stdout.write; const origKittyId = process.env['KITTY_WINDOW_ID']; process.env['KITTY_WINDOW_ID'] = '1'; t.teardown(() => { if (origKittyId === undefined) { delete process.env['KITTY_WINDOW_ID']; } else { process.env['KITTY_WINDOW_ID'] = origKittyId; } }); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'auto'}, }); // The enable sequence should have been written t.true(writtenStrings.includes('\u001B[>1u')); unmount(); }, ); test.serial( 'kitty protocol - auto detection handles Uint8Array query response', t => { const {stdout, write} = createFakeStdout(); const stdin = createFakeStdin(); const origKittyId = process.env['KITTY_WINDOW_ID']; process.env['KITTY_WINDOW_ID'] = '1'; t.teardown(() => { if (origKittyId === undefined) { delete process.env['KITTY_WINDOW_ID']; } else { process.env['KITTY_WINDOW_ID'] = origKittyId; } }); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'auto'}, }); // Respond with Uint8Array instead of string const response = Buffer.from('\u001B[?1u'); stdin.emit('data', new Uint8Array(response)); // The enable sequence should have been written const strings = getWrittenStrings(write); t.true(strings.includes('\u001B[>1u')); unmount(); }, ); test.serial( 'kitty protocol - auto detection preserves split UTF-8 input bytes', async t => { const {stdout} = createFakeStdout(); const stdin = createFakeStdin(); const unshifted: Uint8Array[] = []; const concatUint8Arrays = (chunks: Uint8Array[]): number[] => { const merged: number[] = []; for (const chunk of chunks) { for (const byte of chunk) { merged.push(byte); } } return merged; }; stdin.unshift = ((chunk: Uint8Array) => { unshifted.push(Uint8Array.from(chunk)); return true; }) as typeof stdin.unshift; const origKittyId = process.env['KITTY_WINDOW_ID']; process.env['KITTY_WINDOW_ID'] = '1'; t.teardown(() => { if (origKittyId === undefined) { delete process.env['KITTY_WINDOW_ID']; } else { process.env['KITTY_WINDOW_ID'] = origKittyId; } }); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'auto'}, }); // Emit one UTF-8 emoji split across chunks during detection. stdin.emit('data', new Uint8Array([0xf0, 0x9f])); stdin.emit('data', new Uint8Array([0x92, 0xa9])); await new Promise(resolve => { setTimeout(resolve, 250); }); t.deepEqual(concatUint8Arrays(unshifted), [0xf0, 0x9f, 0x92, 0xa9]); unmount(); }, ); test.serial( 'kitty protocol - auto detection timeout does not leak partial query response', async t => { const {stdout} = createFakeStdout(); const stdin = createFakeStdin(); const unshifted: Uint8Array[] = []; stdin.unshift = ((chunk: Uint8Array) => { unshifted.push(Uint8Array.from(chunk)); return true; }) as typeof stdin.unshift; const origKittyId = process.env['KITTY_WINDOW_ID']; process.env['KITTY_WINDOW_ID'] = '1'; t.teardown(() => { if (origKittyId === undefined) { delete process.env['KITTY_WINDOW_ID']; } else { process.env['KITTY_WINDOW_ID'] = origKittyId; } }); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'auto'}, }); // Simulate partial terminal response that times out before completion. stdin.emit('data', '\u001B[?1'); await new Promise(resolve => { setTimeout(resolve, 250); }); t.is(unshifted.length, 0); unmount(); }, ); test.serial( 'kitty protocol - auto detection timeout preserves query prefix without digits', async t => { const {stdout, write} = createFakeStdout(); const stdin = createFakeStdin(); const unshifted: Uint8Array[] = []; stdin.unshift = ((chunk: Uint8Array) => { unshifted.push(Uint8Array.from(chunk)); return true; }) as typeof stdin.unshift; const origKittyId = process.env['KITTY_WINDOW_ID']; process.env['KITTY_WINDOW_ID'] = '1'; t.teardown(() => { if (origKittyId === undefined) { delete process.env['KITTY_WINDOW_ID']; } else { process.env['KITTY_WINDOW_ID'] = origKittyId; } }); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'auto'}, }); stdin.emit('data', '\u001B[?'); await new Promise(resolve => { setTimeout(resolve, 250); }); const strings = getWrittenStrings(write); const enableCount = strings.filter(s => s === '\u001B[>1u').length; t.is(enableCount, 0); t.deepEqual( unshifted.map(chunk => [...chunk]), [[0x1b, 0x5b, 0x3f]], ); unmount(); }, ); test.serial( 'kitty protocol - auto detection ignores query response without digits', async t => { const {stdout, write} = createFakeStdout(); const stdin = createFakeStdin(); const unshifted: Uint8Array[] = []; stdin.unshift = ((chunk: Uint8Array) => { unshifted.push(Uint8Array.from(chunk)); return true; }) as typeof stdin.unshift; const origKittyId = process.env['KITTY_WINDOW_ID']; process.env['KITTY_WINDOW_ID'] = '1'; t.teardown(() => { if (origKittyId === undefined) { delete process.env['KITTY_WINDOW_ID']; } else { process.env['KITTY_WINDOW_ID'] = origKittyId; } }); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'auto'}, }); stdin.emit('data', '\u001B[?u'); await new Promise(resolve => { setTimeout(resolve, 250); }); const strings = getWrittenStrings(write); const enableCount = strings.filter(s => s === '\u001B[>1u').length; t.is(enableCount, 0); t.deepEqual( unshifted.map(chunk => [...chunk]), [[0x1b, 0x5b, 0x3f, 0x75]], ); unmount(); }, ); test.serial( 'kitty protocol - auto detection preserves invalid query-like escape sequence', async t => { const {stdout, write} = createFakeStdout(); const stdin = createFakeStdin(); const unshifted: Uint8Array[] = []; stdin.unshift = ((chunk: Uint8Array) => { unshifted.push(Uint8Array.from(chunk)); return true; }) as typeof stdin.unshift; const origKittyId = process.env['KITTY_WINDOW_ID']; process.env['KITTY_WINDOW_ID'] = '1'; t.teardown(() => { if (origKittyId === undefined) { delete process.env['KITTY_WINDOW_ID']; } else { process.env['KITTY_WINDOW_ID'] = origKittyId; } }); const {unmount} = render(Hello, { stdout, stdin, kittyKeyboard: {mode: 'auto'}, }); stdin.emit('data', '\u001B[?1x'); await new Promise(resolve => { setTimeout(resolve, 250); }); const strings = getWrittenStrings(write); const enableCount = strings.filter(s => s === '\u001B[>1u').length; t.is(enableCount, 0); t.deepEqual( unshifted.map(chunk => [...chunk]), [[0x1b, 0x5b, 0x3f, 0x31, 0x78]], ); unmount(); }, ); // --- Space and return text input tests --- test('kitty protocol - space key has text field set to space character', t => { const result = parseKeypress(kittyKey(32)); t.is(result.text, ' '); }); test('kitty protocol - return key has text field set to carriage return', t => { const result = parseKeypress(kittyKey(13)); t.is(result.text, '\r'); }); ================================================ FILE: test/log-update.tsx ================================================ import test from 'ava'; import ansiEscapes from 'ansi-escapes'; import logUpdate from '../src/log-update.js'; import createStdout from './helpers/create-stdout.js'; test('standard rendering - renders and updates output', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render('Hello\n'); t.is((stdout.write as any).callCount, 1); t.is((stdout.write as any).firstCall.args[0], 'Hello\n'); render('World\n'); t.is((stdout.write as any).callCount, 2); t.true( ((stdout.write as any).secondCall.args[0] as string).includes('World'), ); }); test('standard rendering - skips identical output', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render('Hello\n'); render('Hello\n'); t.is((stdout.write as any).callCount, 1); }); test('incremental rendering - renders and updates output', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Hello\n'); t.is((stdout.write as any).callCount, 1); t.is((stdout.write as any).firstCall.args[0], 'Hello\n'); render('World\n'); t.is((stdout.write as any).callCount, 2); t.true( ((stdout.write as any).secondCall.args[0] as string).includes('World'), ); }); test('incremental rendering - skips identical output', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Hello\n'); render('Hello\n'); t.is((stdout.write as any).callCount, 1); }); test('incremental rendering - surgical updates', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Line 1\nLine 2\nLine 3\n'); render('Line 1\nUpdated\nLine 3\n'); const secondCall = (stdout.write as any).secondCall.args[0] as string; t.true(secondCall.includes(ansiEscapes.cursorNextLine)); // Skips unchanged lines t.true(secondCall.includes('Updated')); // Only updates changed line t.false(secondCall.includes('Line 1')); // Doesn't rewrite unchanged t.false(secondCall.includes('Line 3')); // Doesn't rewrite unchanged }); test('incremental rendering - clears extra lines when output shrinks', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Line 1\nLine 2\nLine 3\n'); render('Line 1\n'); const secondCall = (stdout.write as any).secondCall.args[0] as string; t.true(secondCall.includes(ansiEscapes.eraseLines(2))); // Erases 2 extra lines }); test('incremental rendering - when output grows', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Line 1\n'); render('Line 1\nLine 2\nLine 3\n'); const secondCall = (stdout.write as any).secondCall.args[0] as string; t.true(secondCall.includes(ansiEscapes.cursorNextLine)); // Skips unchanged first line t.true(secondCall.includes('Line 2')); // Adds new line t.true(secondCall.includes('Line 3')); // Adds new line t.false(secondCall.includes('Line 1')); // Doesn't rewrite unchanged }); test('incremental rendering - single write call with multiple surgical updates', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render( 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n', ); render( 'Line 1\nUpdated 2\nLine 3\nUpdated 4\nLine 5\nUpdated 6\nLine 7\nUpdated 8\nLine 9\nUpdated 10\n', ); t.is((stdout.write as any).callCount, 2); // Only 2 writes total (initial + update) }); test('incremental rendering - shrinking output keeps screen tight', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Line 1\nLine 2\nLine 3\n'); render('Line 1\nLine 2\n'); render('Line 1\n'); const thirdCall = stdout.get(); t.is( thirdCall, ansiEscapes.eraseLines(2) + // Erase Line 2 and ending cursorNextLine ansiEscapes.cursorUp(1) + // Move to beginning of Line 1 ansiEscapes.cursorNextLine, // Move to next line after Line 1 ); }); test('incremental rendering - clear() fully resets incremental state', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Line 1\nLine 2\nLine 3\n'); render.clear(); render('Line 1\n'); const afterClear = stdout.get(); t.is(afterClear, ansiEscapes.eraseLines(0) + 'Line 1\n'); // Should do a fresh write }); test('incremental rendering - done() resets before next render', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Line 1\nLine 2\nLine 3\n'); render.done(); render('Line 1\n'); const afterDone = stdout.get(); t.is(afterDone, ansiEscapes.eraseLines(0) + 'Line 1\n'); // Should do a fresh write }); test('incremental rendering - multiple consecutive clear() calls (should be harmless no-ops)', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Line 1\nLine 2\nLine 3\n'); render.clear(); render.clear(); render.clear(); t.is((stdout.write as any).callCount, 4); // Initial render + 3 clears (each writes eraseLines) // Verify state is properly reset after multiple clears render('New content\n'); const afterClears = stdout.get(); t.is(afterClears, ansiEscapes.eraseLines(0) + 'New content\n'); // Should do a fresh write }); test('incremental rendering - sync() followed by update (assert incremental path is used)', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render.sync('Line 1\nLine 2\nLine 3\n'); t.is((stdout.write as any).callCount, 0); // The sync() call shouldn't write to stdout render('Line 1\nUpdated\nLine 3\n'); t.is((stdout.write as any).callCount, 1); const firstCall = (stdout.write as any).firstCall.args[0] as string; t.true(firstCall.includes(ansiEscapes.cursorNextLine)); // Skips unchanged lines t.true(firstCall.includes('Updated')); // Only updates changed line t.false(firstCall.includes('Line 1')); // Doesn't rewrite unchanged t.false(firstCall.includes('Line 3')); // Doesn't rewrite unchanged }); // Cursor positioning tests const showCursorEscape = '\u001B[?25h'; const hideCursorEscape = '\u001B[?25l'; const renderingModes = [ {name: 'standard rendering', incremental: false}, {name: 'incremental rendering', incremental: true}, ] as const; const createRenderForMode = (incremental: boolean) => { const stdout = createStdout(); const render = incremental ? logUpdate.create(stdout, {showCursor: true, incremental: true}) : logUpdate.create(stdout, {showCursor: true}); return {stdout, render}; }; test('standard rendering - positions cursor after output when cursorPosition is set', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render.setCursorPosition({x: 5, y: 1}); render('Line 1\nLine 2\nLine 3\n'); const written = (stdout.write as any).firstCall.args[0] as string; // Output is "Line 1\nLine 2\nLine 3\n" (3 visible lines) // Cursor after write is at line 3 (0-indexed), col 0 // To reach y=1: cursorUp(3 - 1) = cursorUp(2) // Then cursorTo(5) and show cursor t.true(written.includes('Line 3')); t.true( written.endsWith( ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape, ), ); }); test('standard rendering - hides cursor before erase when cursor was previously shown', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render.setCursorPosition({x: 0, y: 0}); render('Hello\n'); render.setCursorPosition({x: 0, y: 0}); render('World\n'); const secondCall = (stdout.write as any).secondCall.args[0] as string; // Should start with hide cursor before erasing t.true(secondCall.startsWith(hideCursorEscape)); // Should end with show cursor at position t.true( secondCall.endsWith( ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(0) + showCursorEscape, ), ); }); test('standard rendering - no cursor positioning when cursorPosition is undefined', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render('Hello\n'); const written = (stdout.write as any).firstCall.args[0] as string; t.false(written.includes(showCursorEscape)); }); test('standard rendering - cursor position at second-to-last line emits cursorUp(1)', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render.setCursorPosition({x: 3, y: 2}); render('Line 1\nLine 2\nLine 3\n'); const written = (stdout.write as any).firstCall.args[0] as string; // Output has 3 visible lines. After write, cursor is at line 3 (past last visible). // To reach y=2: cursorUp(3 - 2) = cursorUp(1) t.true( written.endsWith( ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(3) + showCursorEscape, ), ); }); for (const {name, incremental} of renderingModes) { test(`${name} - clear() returns cursor to bottom before erasing`, t => { const {stdout, render} = createRenderForMode(incremental); render.setCursorPosition({x: 5, y: 0}); render('Line 1\nLine 2\nLine 3\n'); render.clear(); const clearCall = (stdout.write as any).secondCall.args[0] as string; // Cursor was at y=0, output had 4 lines (3 visible + trailing newline). // clear() should: hide cursor, move down to bottom (from y=0 to line 3), then erase t.true(clearCall.includes(hideCursorEscape)); t.true(clearCall.includes(ansiEscapes.cursorDown(3))); t.true(clearCall.includes(ansiEscapes.eraseLines(4))); }); } test('standard rendering - clearing cursor position stops cursor positioning', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render.setCursorPosition({x: 0, y: 0}); render('Hello\n'); render.setCursorPosition(undefined); render('World\n'); const secondCall = (stdout.write as any).secondCall.args[0] as string; t.false(secondCall.includes(showCursorEscape)); }); test('incremental rendering - positions cursor after surgical updates', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render.setCursorPosition({x: 5, y: 1}); render('Line 1\nLine 2\nLine 3\n'); const written = (stdout.write as any).firstCall.args[0] as string; // After incremental write, cursor is at line 3 (past last visible) // To reach y=1: cursorUp(3 - 1) = cursorUp(2) t.true( written.endsWith( ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape, ), ); }); test('incremental rendering - positions cursor after update', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render.setCursorPosition({x: 2, y: 0}); render('Line 1\nLine 2\nLine 3\n'); render.setCursorPosition({x: 2, y: 0}); render('Line 1\nUpdated\nLine 3\n'); const secondCall = (stdout.write as any).secondCall.args[0] as string; // After incremental update, cursor is at line 3 // To reach y=0: cursorUp(3) t.true( secondCall.endsWith( ansiEscapes.cursorUp(3) + ansiEscapes.cursorTo(2) + showCursorEscape, ), ); }); for (const {name, incremental} of renderingModes) { test(`${name} - repositions cursor when only cursor position changes (same output)`, t => { const {stdout, render} = createRenderForMode(incremental); render.setCursorPosition({x: 2, y: 0}); render('Hello\n'); t.is((stdout.write as any).callCount, 1); // Same output, but cursor moved (simulates space input where output is padded identically) render.setCursorPosition({x: 3, y: 0}); render('Hello\n'); t.is((stdout.write as any).callCount, 2); const secondCall = (stdout.write as any).secondCall.args[0] as string; // Should reposition cursor: hide + return to bottom + move to new position + show t.true(secondCall.includes(showCursorEscape)); t.true(secondCall.endsWith(ansiEscapes.cursorTo(3) + showCursorEscape)); }); } test('standard rendering - returns to bottom before erase when cursor was positioned', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render.setCursorPosition({x: 0, y: 0}); render('Line 1\nLine 2\nLine 3\n'); render.setCursorPosition({x: 5, y: 0}); render('Line A\nLine B\nLine C\n'); const secondCall = (stdout.write as any).secondCall.args[0] as string; // Should: hide cursor, move down to bottom (from y=0 to line 3), then erase + rewrite t.true(secondCall.startsWith(hideCursorEscape)); t.true(secondCall.includes(ansiEscapes.cursorDown(3))); t.true(secondCall.includes('Line A')); }); for (const {name, incremental} of renderingModes) { test(`${name} - sync() resets cursor state`, t => { const {stdout, render} = createRenderForMode(incremental); render.setCursorPosition({x: 5, y: 0}); render('Line 1\nLine 2\nLine 3\n'); // Sync() simulates clearTerminal path: screen is fully reset render.sync('Fresh output\n'); // Next render should NOT include hideCursor + cursorDown (return-to-bottom prefix) // because sync() should have reset previousCursorPosition and cursorWasShown render('Updated output\n'); const afterSync = stdout.get(); t.false(afterSync.includes(hideCursorEscape)); t.false(afterSync.includes(ansiEscapes.cursorDown(3))); }); } for (const {name, incremental} of renderingModes) { test(`${name} - sync() writes cursor suffix when cursor is dirty`, t => { const {stdout, render} = createRenderForMode(incremental); render.setCursorPosition({x: 5, y: 1}); render.sync('Line 1\nLine 2\nLine 3\n'); // Sync() should write cursor suffix to position cursor // 3 visible lines, cursor at y=1 → cursorUp(3-1) = cursorUp(2) t.is((stdout.write as any).callCount, 1); const written = (stdout.write as any).firstCall.args[0] as string; t.is( written, ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape, ); }); } for (const {name, incremental} of renderingModes) { test(`${name} - sync() with cursor sets cursorWasShown for next render`, t => { const {stdout, render} = createRenderForMode(incremental); render.setCursorPosition({x: 5, y: 1}); render.sync('Line 1\nLine 2\nLine 3\n'); // Next render should hide cursor before erasing (cursorWasShown = true from sync) render('Updated\n'); const renderCall = stdout.get(); t.true(renderCall.startsWith(hideCursorEscape)); }); } for (const {name, incremental} of renderingModes) { test(`${name} - sync() hides cursor when previous render showed cursor`, t => { const {stdout, render} = createRenderForMode(incremental); render.setCursorPosition({x: 5, y: 1}); render('Line 1\nLine 2\nLine 3\n'); t.is((stdout.write as any).callCount, 1); render.sync('Fresh output\n'); t.is((stdout.write as any).callCount, 2); t.is((stdout.write as any).secondCall.args[0] as string, hideCursorEscape); }); } test('standard rendering - sync() without cursor does not write to stream', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, {showCursor: true}); render.sync('Line 1\nLine 2\nLine 3\n'); t.is((stdout.write as any).callCount, 0); }); // No-trailing-newline tests (fullscreen mode) test('incremental rendering - no trailing newline: trailing to no-trailing transition', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('A\nB\n'); render('A\nB'); const secondCall = (stdout.write as any).secondCall.args[0] as string; // Both lines are unchanged, so only cursor movement should occur. // The key is that the cursor does NOT overshoot past line B. t.true(secondCall.includes(ansiEscapes.cursorNextLine)); // Skip unchanged A t.false(secondCall.endsWith('\n')); // No trailing newline in output }); test('incremental rendering - no trailing newline: no-trailing to no-trailing update', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('A\nB'); render('A\nC'); const secondCall = (stdout.write as any).secondCall.args[0] as string; t.true(secondCall.includes(ansiEscapes.cursorNextLine)); // Skip unchanged A t.true(secondCall.includes('C')); // Updates B to C t.false(secondCall.endsWith('\n')); // No trailing newline }); test('incremental rendering - no trailing newline: shrink', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('A\nB'); render('A'); const secondCall = (stdout.write as any).secondCall.args[0] as string; // Should erase 1 extra line (B), not over-erase A // previousVisible=2, visibleCount=1, no trailing newline -> eraseLines(2-1+0) = eraseLines(1) t.true(secondCall.includes(ansiEscapes.eraseLines(1))); t.false(secondCall.endsWith('\n')); // No trailing newline }); test('incremental rendering - no trailing newline: grow', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('A'); render('A\nB\nC'); const secondCall = (stdout.write as any).secondCall.args[0] as string; t.true(secondCall.includes('B')); // New line B t.true(secondCall.includes('C')); // New line C t.false(secondCall.endsWith('\n')); // No trailing newline }); test('incremental rendering - no trailing newline: unchanged lines do not overshoot cursor', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('A\nB'); render('A\nB'); // Identical - should be skipped entirely t.is((stdout.write as any).callCount, 1); // No second write (identical) // Now change only the first line render('X\nB'); const thirdCall = (stdout.write as any).secondCall.args[0] as string; // Should write X with newline to advance to B's line, then skip B. // The buffer ends with the \n that moves to B's line, but no extra // cursorNextLine past B -- the cursor stays on the last visible line. t.true(thirdCall.includes('X')); // Verify no cursorNextLine appears after B's position (B is unchanged // and last, so no cursor movement is emitted for it) const lastCursorNextLine = thirdCall.lastIndexOf(ansiEscapes.cursorNextLine); t.is(lastCursorNextLine, -1); // No cursorNextLine at all since A is changed (written) not skipped }); test('incremental rendering - render to empty string (full clear vs early exit)', t => { const stdout = createStdout(); const render = logUpdate.create(stdout, { showCursor: true, incremental: true, }); render('Line 1\nLine 2\nLine 3\n'); render('\n'); t.is((stdout.write as any).callCount, 2); const secondCall = (stdout.write as any).secondCall.args[0] as string; t.is(secondCall, ansiEscapes.eraseLines(4) + '\n'); // Erases all 4 lines + writes single newline // Rendering empty string again should be skipped (identical output) render('\n'); t.is((stdout.write as any).callCount, 2); // No additional write }); ================================================ FILE: test/margin.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; test('margin', t => { const output = renderToString( X , ); t.is(output, '\n\n X\n\n'); }); test('margin X', t => { const output = renderToString( X Y , ); t.is(output, ' X Y'); }); test('margin Y', t => { const output = renderToString( X , ); t.is(output, '\n\nX\n\n'); }); test('margin top', t => { const output = renderToString( X , ); t.is(output, '\n\nX'); }); test('margin bottom', t => { const output = renderToString( X , ); t.is(output, 'X\n\n'); }); test('margin left', t => { const output = renderToString( X , ); t.is(output, ' X'); }); test('margin right', t => { const output = renderToString( X Y , ); t.is(output, 'X Y'); }); test('nested margin', t => { const output = renderToString( X , ); t.is(output, '\n\n\n\n X\n\n\n\n'); }); test('margin with multiline string', t => { const output = renderToString( {'A\nB'} , ); t.is(output, '\n\n A\n B\n\n'); }); test('apply margin to text with newlines', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, '\n Hello\n World\n'); }); test('apply margin to wrapped text', t => { const output = renderToString( Hello World , ); t.is(output, '\n Hello\n World\n'); }); // Concurrent mode tests test('margin - concurrent', async t => { const output = await renderToStringAsync( X , ); t.is(output, '\n\n X\n\n'); }); test('nested margin - concurrent', async t => { const output = await renderToStringAsync( X , ); t.is(output, '\n\n\n\n X\n\n\n\n'); }); ================================================ FILE: test/measure-element.tsx ================================================ import React, {useState, useRef, useEffect, useLayoutEffect} from 'react'; import test from 'ava'; import delay from 'delay'; import stripAnsi from 'strip-ansi'; import { Box, Text, render, measureElement, type DOMElement, } from '../src/index.js'; import createStdout from './helpers/create-stdout.js'; test('measure element', async t => { const stdout = createStdout(); function Test() { const [width, setWidth] = useState(0); const ref = useRef(null); useEffect(() => { if (!ref.current) { return; } setWidth(measureElement(ref.current).width); }, []); return ( Width: {width} ); } render(, {stdout, debug: true}); t.is((stdout.write as any).firstCall.args[0], 'Width: 0'); await delay(100); t.is((stdout.write as any).lastCall.args[0], 'Width: 100'); }); test('measure element after state update', async t => { const stdout = createStdout(); let setTestItems!: (items: string[]) => void; function Test() { const [items, setItems] = useState([]); const [height, setHeight] = useState(0); const ref = useRef(null); setTestItems = setItems; useEffect(() => { if (!ref.current) { return; } setHeight(measureElement(ref.current).height); }, [items.length]); return ( {items.map(item => ( {item} ))} Height: {height} ); } render(, {stdout, debug: true}); await delay(50); setTestItems(['line 1', 'line 2', 'line 3']); await delay(50); t.is( stripAnsi((stdout.write as any).lastCall.firstArg as string).trim(), 'line 1\nline 2\nline 3\nHeight: 3', ); }); test('measure element after multiple state updates', async t => { const stdout = createStdout(); let setTestItems!: (items: string[]) => void; function Test() { const [items, setItems] = useState([]); const [height, setHeight] = useState(0); const ref = useRef(null); setTestItems = setItems; useEffect(() => { if (!ref.current) { return; } setHeight(measureElement(ref.current).height); }, [items.length]); return ( {items.map(item => ( {item} ))} Height: {height} ); } render(, {stdout, debug: true}); await delay(50); setTestItems(['line 1', 'line 2', 'line 3']); await delay(50); setTestItems(['line 1']); await delay(50); t.is( stripAnsi((stdout.write as any).lastCall.firstArg as string).trim(), 'line 1\nHeight: 1', ); }); test('measure element in useLayoutEffect after state update', async t => { const stdout = createStdout(); let setTestItems!: (items: string[]) => void; function Test() { const [items, setItems] = useState([]); const [height, setHeight] = useState(0); const ref = useRef(null); setTestItems = setItems; useLayoutEffect(() => { if (!ref.current) { return; } setHeight(measureElement(ref.current).height); }, [items.length]); return ( {items.map(item => ( {item} ))} Height: {height} ); } render(, {stdout, debug: true}); await delay(50); setTestItems(['line 1', 'line 2', 'line 3']); await delay(50); t.is( stripAnsi((stdout.write as any).lastCall.firstArg as string).trim(), 'line 1\nline 2\nline 3\nHeight: 3', ); }); test.serial('calculate layout while rendering is throttled', async t => { const stdout = createStdout(); function Test() { const [width, setWidth] = useState(0); const ref = useRef(null); useEffect(() => { if (!ref.current) { return; } setWidth(measureElement(ref.current).width); }, []); return ( Width: {width} ); } const {rerender} = render(null, {stdout, patchConsole: false}); rerender(); await delay(50); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call const writes: string[] = (stdout.write as any) .getCalls() .map((c: any) => c.args[0] as string) .filter( (w: string) => !w.startsWith('\u001B[?25') && !w.startsWith('\u001B[?2026'), ); const lastContentWrite = writes.at(-1)!; t.is(stripAnsi(lastContentWrite).trim(), 'Width: 100'); }); ================================================ FILE: test/measure-text.tsx ================================================ import test from 'ava'; import measureText from '../src/measure-text.js'; test('measure single word', t => { t.deepEqual(measureText('constructor'), {width: 11, height: 1}); }); test('measure empty string', t => { t.deepEqual(measureText(''), {width: 0, height: 0}); }); test('measure multiline text', t => { const result = measureText('hello\nworld'); t.is(result.width, 5); t.is(result.height, 2); }); test('measure multiline text with varying line lengths', t => { const result = measureText('a\nfoo\nhi'); t.is(result.width, 3); t.is(result.height, 3); }); test('measure text with trailing newline', t => { const result = measureText('hello\n'); t.is(result.width, 5); t.is(result.height, 2); }); test('measure text with only newlines', t => { const result = measureText('\n\n'); t.is(result.width, 0); t.is(result.height, 3); }); test('returns cached result on repeated calls', t => { const first = measureText('cached-test'); t.is(first.width, 11); t.is(first.height, 1); const second = measureText('cached-test'); t.is(first, second); }); test('measure text with ANSI escape sequences', t => { const result = measureText('\u001B[31mred\u001B[0m'); t.is(result.width, 3); t.is(result.height, 1); }); test('measure text with 256-color ANSI', t => { const result = measureText('\u001B[38;5;196mred\u001B[0m'); t.is(result.width, 3); t.is(result.height, 1); }); test('measure text with wide characters', t => { const result = measureText('你好'); t.is(result.width, 4); t.is(result.height, 1); }); test('measure text with emoji', t => { const result = measureText('🍔'); t.is(result.width, 2); t.is(result.height, 1); }); test('measure multiline with wide characters', t => { const result = measureText('🍔🍟\nabc'); t.is(result.width, 4); t.is(result.height, 2); }); ================================================ FILE: test/overflow.tsx ================================================ import React from 'react'; import test from 'ava'; import boxen, {type Options} from 'boxen'; import sliceAnsi from 'slice-ansi'; import {Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; const box = (text: string, options?: Options): string => { return boxen(text, { ...options, borderStyle: 'round', }); }; const clipX = (text: string, columns: number): string => { return text .split('\n') .map(line => sliceAnsi(line, 0, columns).trim()) .join('\n'); }; test('overflowX - single text node in a box inside overflow container', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello'); }); test('overflowX - single text node inside overflow container with border', t => { const output = renderToString( Hello World , ); t.is(output, box('Hell')); }); test('overflowX - single text node in a box with border inside overflow container', t => { const output = renderToString( Hello World , ); t.is(output, clipX(box('Hello'), 6)); }); test('overflowX - multiple text nodes in a box inside overflow container', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello'); }); test('overflowX - multiple text nodes in a box inside overflow container with border', t => { const output = renderToString( Hello World , ); t.is(output, box('Hello ')); }); test('overflowX - multiple text nodes in a box with border inside overflow container', t => { const output = renderToString( Hello World , ); t.is(output, clipX(box('HelloWo\n'), 8)); }); test('overflowX - multiple boxes inside overflow container', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello'); }); test('overflowX - multiple boxes inside overflow container with border', t => { const output = renderToString( Hello World , ); t.is(output, box('Hello ')); }); test('overflowX - box before left edge of overflow container', t => { const output = renderToString( Hello , ); t.is(output, ''); }); test('overflowX - box before left edge of overflow container with border', t => { const output = renderToString( Hello , ); t.is(output, box(' '.repeat(4))); }); test('overflowX - box intersecting with left edge of overflow container', t => { const output = renderToString( Hello World , ); t.is(output, 'lo Wor'); }); test('overflowX - box intersecting with left edge of overflow container with border', t => { const output = renderToString( Hello World , ); t.is(output, box('lo Wor')); }); test('overflowX - box after right edge of overflow container', t => { const output = renderToString( Hello , ); t.is(output, ''); }); test('overflowX - box intersecting with right edge of overflow container', t => { const output = renderToString( Hello , ); t.is(output, ' Hel'); }); test('overflowY - single text node inside overflow container', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, 'Hello'); }); test('overflowY - single text node inside overflow container with border', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, box('Hello'.padEnd(18, ' '))); }); test('overflowY - multiple boxes inside overflow container', t => { const output = renderToString( Line #1 Line #2 Line #3 Line #4 , ); t.is(output, 'Line #1\nLine #2'); }); test('overflowY - multiple boxes inside overflow container with border', t => { const output = renderToString( Line #1 Line #2 Line #3 Line #4 , ); t.is(output, box('Line #1\nLine #2')); }); test('overflowY - box above top edge of overflow container', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, ''); }); test('overflowY - box above top edge of overflow container with border', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, box(' '.repeat(5))); }); test('overflowY - box intersecting with top edge of overflow container', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, 'World'); }); test('overflowY - box intersecting with top edge of overflow container with border', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, box('World')); }); test('overflowY - box below bottom edge of overflow container', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, ''); }); test('overflowY - box below bottom edge of overflow container with border', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, box(' '.repeat(5))); }); test('overflowY - box intersecting with bottom edge of overflow container', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, 'Hello'); }); test('overflowY - box intersecting with bottom edge of overflow container with border', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, box('Hello')); }); test('overflow - single text node inside overflow container', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, 'Hello\n'); }); test('overflow - single text node inside overflow container with border', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, `${box('Hello ')}\n`); }); test('overflow - multiple boxes inside overflow container', t => { const output = renderToString( TL{'\n'}BL TR{'\n'}BR , ); t.is(output, 'TLTR\n'); }); test('overflow - multiple boxes inside overflow container with border', t => { const output = renderToString( TL{'\n'}BL TR{'\n'}BR , ); t.is(output, `${box('TLTR')}\n`); }); test('overflow - box intersecting with top left edge of overflow container', t => { const output = renderToString( AAAA{'\n'}BBBB{'\n'}CCCC{'\n'}DDDD , ); t.is(output, 'CC\nDD\n\n'); }); test('overflow - box intersecting with top right edge of overflow container', t => { const output = renderToString( AAAA{'\n'}BBBB{'\n'}CCCC{'\n'}DDDD , ); t.is(output, ' CC\n DD\n\n'); }); test('overflow - box intersecting with bottom left edge of overflow container', t => { const output = renderToString( AAAA{'\n'}BBBB{'\n'}CCCC{'\n'}DDDD , ); t.is(output, '\n\nAA\nBB'); }); test('overflow - box intersecting with bottom right edge of overflow container', t => { const output = renderToString( AAAA{'\n'}BBBB{'\n'}CCCC{'\n'}DDDD , ); t.is(output, '\n\n AA\n BB'); }); test('nested overflow', t => { const output = renderToString( AAAA{'\n'}BBBB{'\n'}CCCC{'\n'}DDDD XXXX{'\n'}YYYY{'\n'}ZZZZ , ); t.is(output, 'AA\nBB\nXXXX\nYYYY\n'); }); // See https://github.com/vadimdemedes/ink/pull/564#issuecomment-1637022742 test('out of bounds writes do not crash', t => { const output = renderToString( , {columns: 10}, ); const expected = boxen('', { width: 12, height: 10, borderStyle: 'round', }) .split('\n') .map((line, index) => { return index === 0 || index === 9 ? line : `${line.slice(0, 10)}${line[11] ?? ''}`; }) .join('\n'); t.is(output, expected); }); // Concurrent mode tests test('overflowX - single text node in a box inside overflow container - concurrent', async t => { const output = await renderToStringAsync( Hello World , ); t.is(output, 'Hello'); }); test('overflowY - single text node inside overflow container - concurrent', async t => { const output = await renderToStringAsync( Hello{'\n'}World , ); t.is(output, 'Hello'); }); test('overflow - single text node inside overflow container - concurrent', async t => { const output = await renderToStringAsync( Hello{'\n'}World , ); t.is(output, 'Hello\n'); }); test('nested overflow - concurrent', async t => { const output = await renderToStringAsync( AAAA{'\n'}BBBB{'\n'}CCCC{'\n'}DDDD XXXX{'\n'}YYYY{'\n'}ZZZZ , ); t.is(output, 'AA\nBB\nXXXX\nYYYY\n'); }); ================================================ FILE: test/padding.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; test('padding', t => { const output = renderToString( X , ); t.is(output, '\n\n X\n\n'); }); test('padding X', t => { const output = renderToString( X Y , ); t.is(output, ' X Y'); }); test('padding Y', t => { const output = renderToString( X , ); t.is(output, '\n\nX\n\n'); }); test('padding top', t => { const output = renderToString( X , ); t.is(output, '\n\nX'); }); test('padding bottom', t => { const output = renderToString( X , ); t.is(output, 'X\n\n'); }); test('padding left', t => { const output = renderToString( X , ); t.is(output, ' X'); }); test('padding right', t => { const output = renderToString( X Y , ); t.is(output, 'X Y'); }); test('nested padding', t => { const output = renderToString( X , ); t.is(output, '\n\n\n\n X\n\n\n\n'); }); test('padding with multiline string', t => { const output = renderToString( {'A\nB'} , ); t.is(output, '\n\n A\n B\n\n'); }); test('apply padding to text with newlines', t => { const output = renderToString( Hello{'\n'}World , ); t.is(output, '\n Hello\n World\n'); }); test('apply padding to wrapped text', t => { const output = renderToString( Hello World , ); t.is(output, '\n Hel\n lo\n Wor\n ld\n'); }); test('text wrapping respects paddingX with flexGrow', t => { // https://github.com/vadimdemedes/ink/issues/584 const output = renderToString( Lorem ipsum dolor sit amet, consectetur adipiscing elit , ); const lines = output.split('\n'); for (const line of lines) { t.true( line.length <= 40, `Line "${line}" exceeds container width of 40 (got ${line.length})`, ); } }); // Concurrent mode tests test('padding - concurrent', async t => { const output = await renderToStringAsync( X , ); t.is(output, '\n\n X\n\n'); }); test('nested padding - concurrent', async t => { const output = await renderToStringAsync( X , ); t.is(output, '\n\n\n\n X\n\n\n\n'); }); ================================================ FILE: test/position.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text, render} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; import createStdout from './helpers/create-stdout.js'; test('absolute position with top and left offsets', t => { const output = renderToString( X , ); t.is(output, '\n X\n'); }); test('absolute position with bottom and right offsets', t => { const output = renderToString( X , ); t.is(output, '\n\n X\n'); }); test('absolute position with percentage offsets', t => { const output = renderToString( X , ); t.is(output, '\n\n X\n'); }); test('absolute position with percentage bottom and right offsets', t => { const output = renderToString( X , ); t.is(output, '\n X\n\n'); }); test('relative position offsets visual position while keeping flow', t => { const output = renderToString( A B , ); t.is(output, ' BA'); }); test('static position ignores offsets', t => { const output = renderToString( A B , ); t.is(output, 'AB'); }); test('static position ignores percentage offsets', t => { const output = renderToString( A B , ); t.is(output, 'AB'); }); test('clears top offset on rerender', t => { const stdout = createStdout(); function Test({top}: {readonly top?: number}) { return ( X ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], '\n X\n'); rerender(); t.is(stdout.write.lastCall.args[0], ' X\n\n'); }); test('clears percentage top and left offsets on rerender', t => { const stdout = createStdout(); function Test({top, left}: {readonly top?: string; readonly left?: string}) { return ( X ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], '\n\n X\n'); rerender(); t.is(stdout.write.lastCall.args[0], 'X\n\n\n'); }); test('clears percentage top and left offsets when props are omitted on rerender', t => { const stdout = createStdout(); function Test({showOffsets}: {readonly showOffsets: boolean}) { return ( X ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], '\n\n X\n'); rerender(); t.is(stdout.write.lastCall.args[0], 'X\n\n\n'); }); test('clears bottom and right offsets on rerender', t => { const stdout = createStdout(); function Test({ bottom, right, }: { readonly bottom?: number; readonly right?: number; }) { return ( X ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], '\n\n X\n'); rerender(); t.is(stdout.write.lastCall.args[0], 'X\n\n\n'); }); test('absolute position with top and left offsets - concurrent', async t => { const output = await renderToStringAsync( X , ); t.is(output, '\n X\n'); }); ================================================ FILE: test/reconciler.tsx ================================================ import React, {Suspense} from 'react'; import test from 'ava'; import chalk from 'chalk'; import {Box, Text, render} from '../src/index.js'; import createStdout from './helpers/create-stdout.js'; test('update child', t => { function Test({update}: {readonly update?: boolean}) { return {update ? 'B' : 'A'}; } const stdoutActual = createStdout(); const stdoutExpected = createStdout(); const actual = render(, { stdout: stdoutActual, debug: true, }); const expected = render(A, { stdout: stdoutExpected, debug: true, }); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); actual.rerender(); expected.rerender(B); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); }); test('update text node', t => { function Test({update}: {readonly update?: boolean}) { return ( Hello {update ? 'B' : 'A'} ); } const stdoutActual = createStdout(); const stdoutExpected = createStdout(); const actual = render(, { stdout: stdoutActual, debug: true, }); const expected = render(Hello A, { stdout: stdoutExpected, debug: true, }); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); actual.rerender(); expected.rerender(Hello B); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); }); test('remove style prop from intrinsic node', t => { function Test({withStyle}: {readonly withStyle: boolean}) { return ( X ); } const stdout = createStdout(); const {rerender} = render(, { stdout, debug: true, }); t.is((stdout.write as any).lastCall.args[0], ' X'); rerender(); t.is((stdout.write as any).lastCall.args[0], 'X'); }); test('append child', t => { function Test({append}: {readonly append?: boolean}) { if (append) { return ( A B ); } return ( A ); } const stdoutActual = createStdout(); const stdoutExpected = createStdout(); const actual = render(, { stdout: stdoutActual, debug: true, }); const expected = render( A , { stdout: stdoutExpected, debug: true, }, ); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); actual.rerender(); expected.rerender( A B , ); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); }); test('insert child between other children', t => { function Test({insert}: {readonly insert?: boolean}) { if (insert) { return ( A B C ); } return ( A C ); } const stdoutActual = createStdout(); const stdoutExpected = createStdout(); const actual = render(, { stdout: stdoutActual, debug: true, }); const expected = render( A C , { stdout: stdoutExpected, debug: true, }, ); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); actual.rerender(); expected.rerender( A B C , ); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); }); test('remove child', t => { function Test({remove}: {readonly remove?: boolean}) { if (remove) { return ( A ); } return ( A B ); } const stdoutActual = createStdout(); const stdoutExpected = createStdout(); const actual = render(, { stdout: stdoutActual, debug: true, }); const expected = render( A B , { stdout: stdoutExpected, debug: true, }, ); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); actual.rerender(); expected.rerender( A , ); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); }); test('reorder children', t => { function Test({reorder}: {readonly reorder?: boolean}) { if (reorder) { return ( B A ); } return ( A B ); } const stdoutActual = createStdout(); const stdoutExpected = createStdout(); const actual = render(, { stdout: stdoutActual, debug: true, }); const expected = render( A B , { stdout: stdoutExpected, debug: true, }, ); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); actual.rerender(); expected.rerender( B A , ); t.is( (stdoutActual.write as any).lastCall.args[0], (stdoutExpected.write as any).lastCall.args[0], ); }); test('replace child node with text', t => { const stdout = createStdout(); function Dynamic({replace}: {readonly replace?: boolean}) { return {replace ? 'x' : test}; } const {rerender} = render(, { stdout, debug: true, }); t.is((stdout.write as any).lastCall.args[0], chalk.green('test')); rerender(); t.is((stdout.write as any).lastCall.args[0], 'x'); }); test('support suspense', async t => { const stdout = createStdout(); let promise: Promise | undefined; let state: 'pending' | 'done' | undefined; let value: string | undefined; const read = () => { if (!promise) { promise = new Promise(resolve => { setTimeout(resolve, 100); }); state = 'pending'; (async () => { await promise; state = 'done'; value = 'Hello World'; })(); } if (state === 'done') { return value; } // eslint-disable-next-line @typescript-eslint/only-throw-error throw promise; }; function Suspendable() { return {read()}; } function Test() { return ( Loading}> ); } const out = render(, { stdout, debug: true, }); t.is((stdout.write as any).lastCall.args[0], 'Loading'); await promise; out.rerender(); t.is((stdout.write as any).lastCall.args[0], 'Hello World'); }); test('support suspense with concurrent mode', async t => { const stdout = createStdout(); let resolvePromise: () => void; const promise = new Promise(resolve => { resolvePromise = resolve; }); // eslint-disable-next-line prefer-const let data: string | undefined; function Suspendable() { if (data === undefined) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw promise; } return {data}; } function Test() { return ( Loading}> ); } const {act} = await import('react'); await act(async () => { render(, { stdout, debug: true, concurrent: true, }); }); t.is((stdout.write as any).lastCall.args[0], 'Loading'); // Resolve the suspense and wait for React to re-render data = 'Hello Concurrent World'; await act(async () => { resolvePromise(); await promise; }); t.is((stdout.write as any).lastCall.args[0], 'Hello Concurrent World'); }); ================================================ FILE: test/render-to-string.tsx ================================================ import test from 'ava'; import chalk from 'chalk'; import boxen from 'boxen'; import React, {useEffect, useLayoutEffect, useState} from 'react'; import { Box, Text, Static, Transform, Newline, Spacer, renderToString, } from '../src/index.js'; // ── Basic rendering ───────────────────────────────────── test('render simple text', t => { const output = renderToString(Hello World); t.is(output, 'Hello World'); }); test('render text with variable', t => { const output = renderToString(Count: {42}); t.is(output, 'Count: 42'); }); test('render nested text components', t => { function World() { return World; } const output = renderToString( Hello , ); t.is(output, 'Hello World'); }); test('render empty fragment', t => { const output = renderToString(<>); // eslint-disable-line react/jsx-no-useless-fragment t.is(output, ''); }); test('render null children', t => { const output = renderToString({null}); t.is(output, ''); }); // ── Layout ────────────────────────────────────────────── test('render box with padding', t => { const output = renderToString( Padded , ); t.is(output, ' Padded'); }); test('render box with flex direction row', t => { const output = renderToString( A B C , ); t.is(output, 'ABC'); }); test('render box with flex direction column', t => { const output = renderToString( Line 1 Line 2 , ); t.is(output, 'Line 1\nLine 2'); }); test('render margin', t => { const output = renderToString( Margined , ); t.is(output, ' Margined'); }); test('render gap between items', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('render box with fixed width and height', t => { const output = renderToString( Hi , ); const lines = output.split('\n'); t.is(lines.length, 3); }); test('render spacer pushes content apart', t => { const output = renderToString( Left Right , ); t.is(output, 'Left Right'); }); test('render newline inserts blank line', t => { const output = renderToString( Above Below , ); t.is(output, 'Above\n\n\nBelow'); }); test('render box with border', t => { const output = renderToString( Bordered , {columns: 20}, ); t.is( output, boxen('Bordered', { width: 20, borderStyle: 'single', }), ); }); // ── Styling ───────────────────────────────────────────── test('render colored text', t => { const output = renderToString(Green); t.is(output, chalk.green('Green')); }); test('render bold text', t => { const output = renderToString(Bold); t.is(output, chalk.bold('Bold')); }); // ── Text wrapping and columns ─────────────────────────── test('render text with wrap', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello\nWorld'); }); test('render text with truncate', t => { const output = renderToString( Hello World , ); t.is(output, 'Hello …'); }); test('default columns is 80', t => { const longText = 'A'.repeat(100); const output = renderToString({longText}); const lines = output.split('\n'); t.is(lines.length, 2); t.is(lines[0], 'A'.repeat(80)); t.is(lines[1], 'A'.repeat(20)); }); test('custom columns option', t => { const longText = 'A'.repeat(50); const output = renderToString({longText}, {columns: 30}); const lines = output.split('\n'); t.is(lines.length, 2); t.is(lines[0], 'A'.repeat(30)); t.is(lines[1], 'A'.repeat(20)); }); // ── Components ────────────────────────────────────────── test('render Transform component', t => { const output = renderToString( output.toUpperCase()}> hello , ); t.is(output, 'HELLO'); }); test('render Static component with items', t => { const items = ['A', 'B', 'C']; const output = renderToString( {item => {item}} Dynamic , ); t.is(output, 'A\nB\nC\nDynamic'); }); test('render static-only output has no trailing newline', t => { const items = ['A', 'B']; const output = renderToString( {item => {item}}, ); t.is(output, 'A\nB'); }); test('render static + dynamic output has exactly one newline between parts', t => { const items = ['A', 'B']; const output = renderToString( {item => {item}} Dynamic , ); t.is(output, 'A\nB\nDynamic'); }); // ── Effect behavior ───────────────────────────────────── test('captures initial render output before effect-driven state updates', t => { function App() { const [text, setText] = useState('Initial'); useEffect(() => { setText('Updated'); }, []); return {text}; } const output = renderToString(); t.is(output, 'Initial'); }); test('useLayoutEffect state updates are reflected in output', t => { function App() { const [text, setText] = useState('Initial'); useLayoutEffect(() => { setText('Layout Updated'); }, []); return {text}; } const output = renderToString(); t.is(output, 'Layout Updated'); }); test('runs effect cleanup on teardown', t => { let cleanupRan = false; function App() { useEffect(() => { return () => { cleanupRan = true; }; }, []); return Cleanup test; } const output = renderToString(); t.is(output, 'Cleanup test'); t.true(cleanupRan); }); // ── Error handling ────────────────────────────────────── test('component that throws propagates the error', t => { function Broken(): React.JSX.Element { throw new Error('Component error'); } t.throws(() => renderToString(), {message: 'Component error'}); }); test('text outside Text component throws', t => { t.throws(() => renderToString({'raw text'}), { message: /must be rendered inside /, }); }); test('subsequent calls work after a component error', t => { function Broken(): React.JSX.Element { throw new Error('Boom'); } t.throws(() => renderToString()); const output = renderToString(Still works); t.is(output, 'Still works'); }); // ── Independence ──────────────────────────────────────── test('can be called multiple times independently', t => { const output1 = renderToString(First); const output2 = renderToString(Second); t.is(output1, 'First'); t.is(output2, 'Second'); }); // ── Deeply nested tree ────────────────────────────────── test('render deeply nested component tree', t => { const output = renderToString( {'Nested '} deep , ); t.true(output.includes('Nested')); t.true(output.includes('deep')); }); ================================================ FILE: test/render.tsx ================================================ import process from 'node:process'; import vm from 'node:vm'; import {spawn as spawnProcess} from 'node:child_process'; import {PassThrough, Writable} from 'node:stream'; import {Buffer} from 'node:buffer'; import url from 'node:url'; import * as path from 'node:path'; import {createRequire} from 'node:module'; import FakeTimers from '@sinonjs/fake-timers'; import {stub} from 'sinon'; import test, {type ExecutionContext} from 'ava'; import React, { type ReactElement, type ReactNode, PureComponent, useEffect, useState, } from 'react'; import ansiEscapes from 'ansi-escapes'; import stripAnsi from 'strip-ansi'; import boxen from 'boxen'; import delay from 'delay'; import {render, Box, Text, useApp, useCursor, useInput} from '../src/index.js'; import {type RenderMetrics} from '../src/ink.js'; import {bsu, esu} from '../src/write-synchronized.js'; import {createStdin, emitReadable} from './helpers/create-stdin.js'; import createStdout from './helpers/create-stdout.js'; const require = createRequire(import.meta.url); // eslint-disable-next-line @typescript-eslint/consistent-type-imports const {spawn} = require('node-pty') as typeof import('node-pty'); const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); const term = (fixture: string, args: string[] = []) => { let resolve: (value?: unknown) => void; let reject: (error: Error) => void; // eslint-disable-next-line promise/param-names const exitPromise = new Promise((resolve2, reject2) => { resolve = resolve2; reject = reject2; }); const env = { ...process.env, // eslint-disable-next-line @typescript-eslint/naming-convention NODE_NO_WARNINGS: '1', }; const ps = spawn( 'node', [ '--import=tsx', path.join(__dirname, `./fixtures/${fixture}.tsx`), ...args, ], { name: 'xterm-color', cols: 100, cwd: __dirname, env, }, ); const result = { write(input: string) { ps.write(input); }, output: '', waitForExit: async () => exitPromise, }; ps.onData(data => { // Strip Synchronized Update Mode sequences (bsu/esu) so tests // only see the actual content, not the transport wrapper. result.output += data .replaceAll('\u001B[?2026h', '') .replaceAll('\u001B[?2026l', ''); }); ps.onExit(({exitCode}) => { if (exitCode === 0) { resolve(); return; } reject(new Error(`Process exited with non-zero exit code: ${exitCode}`)); }); return result; }; const countOccurrences = (text: string, searchValue: string): number => { if (searchValue === '') { return 0; } return text.split(searchValue).length - 1; }; const isWriteBarrierChunk = (chunk: string | Uint8Array): boolean => (typeof chunk === 'string' && chunk === '') || (chunk instanceof Uint8Array && chunk.length === 0); const toRenderedChunk = (chunk: string | Uint8Array): string => stripAnsi(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); const isCursorOrSyncEscape = (chunk: string | Uint8Array): boolean => { const str = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); return str.startsWith('\u001B[?25') || str === bsu || str === esu; }; const isRenderContent = (chunk: string | Uint8Array): boolean => !isWriteBarrierChunk(chunk) && !isCursorOrSyncEscape(chunk); const getContentWrites = (writeSpy: any): string[] => (writeSpy.args as string[][]) .map((args: string[]) => args[0]!) .filter((w: string) => isRenderContent(w)); const createDelayedWriteCallbackStdout = ({ shouldDelay, onDelayElapsed, delayMs = 150, }: { readonly shouldDelay: (chunk: string | Uint8Array) => boolean; readonly onDelayElapsed: () => void; readonly delayMs?: number; }): NodeJS.WriteStream => { let didDelayOnce = false; const stdout = new Writable({ write( chunk: string | Uint8Array, _encoding: BufferEncoding, callback: (error?: Error) => void, ) { if (!didDelayOnce && shouldDelay(chunk)) { didDelayOnce = true; setTimeout(() => { onDelayElapsed(); callback(); }, delayMs); return; } callback(); }, }) as unknown as NodeJS.WriteStream; stdout.columns = 100; stdout.isTTY = true; return stdout; }; type Issue450Fixture = | 'issue-450-full-height-rerender' | 'issue-450-full-height-rerender-with-marker' | 'issue-450-height-minus-one-rerender' | 'issue-450-full-height-with-static-rerender' | 'issue-450-initial-overflow' | 'issue-450-initial-fullscreen' | 'issue-450-grow-to-fullscreen-rerender' | 'issue-450-shrink-from-fullscreen-rerender' | 'issue-450-shrink-from-overflow-rerender' | 'issue-450-static-shrink-from-fullscreen-rerender'; const runIssue450Fixture = async ( fixture: Issue450Fixture, rows = 6, ): Promise => { const processResult = term(fixture, [String(rows)]); await processResult.waitForExit(); return processResult.output; }; const runNonTtyFixture = async ( fixture: string, args: string[] = [], ): Promise => { let output = ''; let errorOutput = ''; const env = { ...process.env, // eslint-disable-next-line @typescript-eslint/naming-convention NODE_NO_WARNINGS: '1', }; // Force non-CI code path while still using a non-TTY stdout stream. env.CI = 'false'; const fixtureProcess = spawnProcess( 'node', [ '--import=tsx', path.join(__dirname, `./fixtures/${fixture}.tsx`), ...args, ], { cwd: __dirname, env, stdio: ['ignore', 'pipe', 'pipe'], }, ); fixtureProcess.stdout.on('data', (data: Uint8Array | string) => { output += typeof data === 'string' ? data : data.toString(); }); fixtureProcess.stderr.on('data', (data: Uint8Array | string) => { errorOutput += typeof data === 'string' ? data : data.toString(); }); const exitCode = await new Promise((resolve, reject) => { fixtureProcess.on('error', reject); fixtureProcess.on('close', code => { resolve(code ?? 0); }); }); if (exitCode !== 0) { throw new Error( `Non-TTY fixture exited with code ${exitCode}: ${errorOutput}`, ); } return output; }; type Issue450FixtureResult = { output: string; clearTerminalCount: number; eraseLineCount: number; }; const getIssue450ControlSequenceCounts = (output: string) => ({ clearTerminalCount: countOccurrences(output, ansiEscapes.clearTerminal), eraseLineCount: countOccurrences(output, ansiEscapes.eraseLines(1)), }); const runIssue450FixtureWithCounts = async ( fixture: Issue450Fixture, rows = 6, ): Promise => { const output = await runIssue450Fixture(fixture, rows); const {clearTerminalCount, eraseLineCount} = getIssue450ControlSequenceCounts(output); return { output, clearTerminalCount, eraseLineCount, }; }; const getOutputBeforeMarker = ( t: ExecutionContext, output: string, marker: string, ): string => { const markerIndex = output.indexOf(marker); t.true(markerIndex >= 0, `Fixture marker "${marker}" should be present`); return markerIndex >= 0 ? output.slice(0, markerIndex) : output; }; const runIssue450FixtureBeforeMarker = async ( t: ExecutionContext, fixture: Issue450Fixture, marker: string, rows = 6, ): Promise => { const output = await runIssue450Fixture(fixture, rows); return getOutputBeforeMarker(t, output, marker); }; const assertIssue450DynamicFrameOutput = ( t: ExecutionContext, output: string, ): void => { t.true( output.includes('frame 8'), 'Fixture should render multiple dynamic frames', ); }; class SynchronousErrorBoundary extends PureComponent< { onError: (error: Error) => void; children?: ReactElement; }, {error?: Error} > { static displayName = 'SynchronousErrorBoundary'; static override getDerivedStateFromError(error: Error) { return {error}; } override state: {error?: Error} = { error: undefined, }; override componentDidCatch(error: Error) { this.props.onError(error); } override render() { if (this.state.error) { return null; } return this.props.children; } } function SynchronousRenderErrorComponent() { throw new Error('Synchronous render error'); } function ThrowingComponentWithBoundary() { const {exit} = useApp(); return ( ); } test.serial('do not erase screen', async t => { const ps = term('erase', ['4']); await ps.waitForExit(); t.false(ps.output.includes(ansiEscapes.clearTerminal)); for (const letter of ['A', 'B', 'C']) { t.true(ps.output.includes(letter)); } }); test.serial( 'do not erase screen where is taller than viewport', async t => { const ps = term('erase-with-static', ['4']); await ps.waitForExit(); t.false(ps.output.includes(ansiEscapes.clearTerminal)); for (const letter of ['A', 'B', 'C', 'D', 'E', 'F']) { t.true(ps.output.includes(letter)); } }, ); test.serial('erase screen', async t => { const ps = term('erase', ['3']); await ps.waitForExit(); t.true(ps.output.includes(ansiEscapes.clearTerminal)); for (const letter of ['A', 'B', 'C']) { t.true(ps.output.includes(letter)); } }); test.serial( 'erase screen where exists but interactive part is taller than viewport', async t => { const ps = term('erase', ['3']); await ps.waitForExit(); t.true(ps.output.includes(ansiEscapes.clearTerminal)); for (const letter of ['A', 'B', 'C']) { t.true(ps.output.includes(letter)); } }, ); test.serial('erase screen where state changes', async t => { const ps = term('erase-with-state-change', ['4']); await ps.waitForExit(); // The final frame is between the last eraseLines sequence and cursorShow // Split on cursorShow to isolate the final rendered content before the cursor is shown const beforeCursorShow = ps.output.split(ansiEscapes.cursorShow)[0]; if (!beforeCursorShow) { t.fail('beforeCursorShow is undefined'); return; } // Find the last occurrence of an eraseLines sequence // eraseLines(1) is the minimal erase pattern used by Ink const eraseLinesPattern = ansiEscapes.eraseLines(1); const lastEraseIndex = beforeCursorShow.lastIndexOf(eraseLinesPattern); const lastFrame = lastEraseIndex === -1 ? beforeCursorShow : beforeCursorShow.slice(lastEraseIndex + eraseLinesPattern.length); const lastFrameContent = stripAnsi(lastFrame); for (const letter of ['A', 'B', 'C']) { t.false(lastFrameContent.includes(letter)); } }); test.serial('erase screen where state changes in small viewport', async t => { const ps = term('erase-with-state-change', ['3']); await ps.waitForExit(); const frames = ps.output.split(ansiEscapes.clearTerminal); const lastFrame = frames.at(-1); for (const letter of ['A', 'B', 'C']) { t.false(lastFrame?.includes(letter)); } }); test.serial( 'fullscreen mode should not add extra newline at the bottom', async t => { const ps = term('fullscreen-no-extra-newline', ['5']); await ps.waitForExit(); t.true(ps.output.includes('Bottom line')); const lastFrame = ps.output.split(ansiEscapes.clearTerminal).at(-1) ?? ''; // Check that the bottom line is at the end without extra newlines // In a 5-line terminal: // Line 1: Fullscreen: top // Lines 2-4: empty (from flexGrow) // Line 5: Bottom line (should be usable) const lines = lastFrame.split('\n'); t.is(lines.length, 5, 'Should have exactly 5 lines for 5-row terminal'); t.true( lines[4]?.includes('Bottom line') ?? false, 'Bottom line should be on line 5', ); }, ); test.serial( '#442: full terminal-size box should not add an extra scroll line', async t => { const rows = 5; const ps = term('issue-442-full-height', [String(rows)]); await ps.waitForExit(); const lastFrame = ps.output.split(ansiEscapes.clearTerminal).at(-1) ?? ''; const lastFrameContent = stripAnsi(lastFrame); const lines = lastFrameContent.split('\n'); t.false( lastFrameContent.endsWith('\n'), 'Should not end with a trailing newline in fullscreen mode', ); t.is( lines.length, rows, 'Should render exactly terminal row count without an extra line', ); t.true(lines.at(-1)?.includes('#442 bottom') ?? false); }, ); test.serial( '#450: full-height rerenders should not repeatedly clear terminal', async t => { const {output, clearTerminalCount, eraseLineCount} = await runIssue450FixtureWithCounts('issue-450-full-height-rerender'); assertIssue450DynamicFrameOutput(t, output); t.true( clearTerminalCount <= 1, `Expected at most one clearTerminal sequence, received ${clearTerminalCount}`, ); t.true( eraseLineCount > 0, 'Expected incremental erase sequences for fullscreen rerenders', ); }, ); test.serial( '#450: initial overflowing frame should not clear terminal', async t => { const renderedMarker = '__INITIAL_OVERFLOW_FRAME_RENDERED__'; const outputBeforeMarker = await runIssue450FixtureBeforeMarker( t, 'issue-450-initial-overflow', renderedMarker, 3, ); t.false( outputBeforeMarker.includes(ansiEscapes.clearTerminal), 'Initial overflowing render should not clear terminal', ); }, ); test.serial( '#450: initial full-height frame should not clear terminal', async t => { const renderedMarker = '__INITIAL_FULLSCREEN_FRAME_RENDERED__'; const outputBeforeMarker = await runIssue450FixtureBeforeMarker( t, 'issue-450-initial-fullscreen', renderedMarker, 3, ); t.false( outputBeforeMarker.includes(ansiEscapes.clearTerminal), 'Initial full-height render should not clear terminal', ); }, ); test.serial( '#450 control: rows - 1 rerenders should avoid clearTerminal', async t => { const {output, clearTerminalCount, eraseLineCount} = await runIssue450FixtureWithCounts('issue-450-height-minus-one-rerender'); assertIssue450DynamicFrameOutput(t, output); t.is(clearTerminalCount, 0); t.true( eraseLineCount > 0, 'Expected incremental erase sequences for non-fullscreen rerenders', ); }, ); test.serial( '#450: full-height rerenders should not clear before unmount', async t => { const renderedMarker = '__FULL_HEIGHT_RERENDER_COMPLETED__'; const outputBeforeMarker = await runIssue450FixtureBeforeMarker( t, 'issue-450-full-height-rerender-with-marker', renderedMarker, ); const {clearTerminalCount} = getIssue450ControlSequenceCounts(outputBeforeMarker); assertIssue450DynamicFrameOutput(t, outputBeforeMarker); t.is(clearTerminalCount, 0); }, ); test.serial( '#450: grow from rows - 1 to full-height should not clear before unmount', async t => { const renderedMarker = '__GROW_TO_FULLSCREEN_RERENDER_COMPLETED__'; const outputBeforeMarker = await runIssue450FixtureBeforeMarker( t, 'issue-450-grow-to-fullscreen-rerender', renderedMarker, ); const {clearTerminalCount} = getIssue450ControlSequenceCounts(outputBeforeMarker); assertIssue450DynamicFrameOutput(t, outputBeforeMarker); t.is(clearTerminalCount, 0); }, ); test.serial( '#450: shrink from full-height to rows - 1 should clear exactly once', async t => { const {output, clearTerminalCount} = await runIssue450FixtureWithCounts( 'issue-450-shrink-from-fullscreen-rerender', ); assertIssue450DynamicFrameOutput(t, output); t.is(clearTerminalCount, 1); }, ); test.serial( '#450: shrink from overflow to rows - 1 should clear exactly once', async t => { const {output, clearTerminalCount} = await runIssue450FixtureWithCounts( 'issue-450-shrink-from-overflow-rerender', ); assertIssue450DynamicFrameOutput(t, output); t.is(clearTerminalCount, 1); }, ); test.serial( '#450: with shrink from full-height should clear exactly once', async t => { const {output, clearTerminalCount} = await runIssue450FixtureWithCounts( 'issue-450-static-shrink-from-fullscreen-rerender', ); t.true(output.includes('#450 static line')); assertIssue450DynamicFrameOutput(t, output); t.is(clearTerminalCount, 1); }, ); test.serial( '#450: non-TTY full-height rerenders should never clear terminal', t => { const rows = 6; const stdout = createStdout(); stdout.rows = rows; const writes = captureWrites(stdout); function NonTtyRerenderTestComponent({ frameCount, }: { readonly frameCount: number; }) { return ( #450 top {`frame ${frameCount}`} #450 bottom ); } const {rerender, unmount} = render( , {stdout}, ); rerender(); rerender(); const {clearTerminalCount} = getIssue450ControlSequenceCounts( writes.join(''), ); t.is(clearTerminalCount, 0); unmount(); }, ); test.serial( '#450: non-TTY overflow transitions should never clear terminal', t => { const rows = 3; const stdout = createStdout(); stdout.rows = rows; const writes = captureWrites(stdout); function NonTtyOverflowTransitionTestComponent({ lineCount, }: { readonly lineCount: number; }) { const lines = []; for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) { lines.push({`line ${lineNumber}`}); } return {lines}; } const {rerender, unmount} = render( , {stdout}, ); rerender(); const {clearTerminalCount} = getIssue450ControlSequenceCounts( writes.join(''), ); t.is(clearTerminalCount, 0); unmount(); }, ); test.serial( '#450: viewport shrink into overflow should clear once', async t => { const rows = 6; const stdout = createTtyStdout(); stdout.rows = rows; const writes = captureWrites(stdout); function ResizeBoundaryTestComponent() { return ( #450 top #450 middle #450 bottom ); } const {unmount} = render(, {stdout}); writes.length = 0; stdout.rows = rows - 1; stdout.emit('resize'); await delay(0); const {clearTerminalCount} = getIssue450ControlSequenceCounts( writes.join(''), ); t.is(clearTerminalCount, 1); unmount(); }, ); test.serial( '#450: non-TTY grow-to-overflow rerender should not clear terminal', async t => { const output = await runNonTtyFixture( 'issue-450-grow-to-overflow-rerender', ['3'], ); t.false(output.includes(ansiEscapes.clearTerminal)); }, ); test.serial('#725: non-TTY child process output is flushed', async t => { const output = await runNonTtyFixture('issue-725-child-process'); const plainOutput = stripAnsi(output); t.true(plainOutput.includes('ready-stdin-not-tty')); t.true(plainOutput.includes('exited')); }); test.serial( '#450: full-height rerenders with should not repeatedly clear terminal', async t => { const {output, clearTerminalCount, eraseLineCount} = await runIssue450FixtureWithCounts( 'issue-450-full-height-with-static-rerender', ); t.true( output.includes('#450 static line'), 'Fixture should emit static output', ); assertIssue450DynamicFrameOutput(t, output); t.true( clearTerminalCount <= 1, `Expected at most one clearTerminal sequence, received ${clearTerminalCount}`, ); t.true( eraseLineCount > 0, 'Expected incremental erase sequences for fullscreen rerenders', ); }, ); test.serial('clear output', async t => { const ps = term('clear'); await ps.waitForExit(); const secondFrame = ps.output.split(ansiEscapes.eraseLines(4))[1]; for (const letter of ['A', 'B', 'C']) { t.false(secondFrame?.includes(letter)); } }); test.serial( 'intercept console methods and display result above output', async t => { const ps = term('console'); await ps.waitForExit(); const frames = ps.output.split(ansiEscapes.eraseLines(2)).map(line => { return stripAnsi(line); }); t.deepEqual(frames, [ 'Hello World\r\n', 'First log\r\nHello World\r\nSecond log\r\n', ]); }, ); test.serial('rerender on resize', async t => { const stdout = createStdout(10); function Test() { return ( Test ); } const {unmount} = render(, {stdout}); const contentWrites = getContentWrites(stdout.write); t.is( stripAnsi(contentWrites[0]!), boxen('Test'.padEnd(8), {borderStyle: 'round'}) + '\n', ); t.is(stdout.listeners('resize').length, 1); stdout.columns = 8; stdout.emit('resize'); await delay(100); const contentWritesAfterResize = getContentWrites(stdout.write); t.is( stripAnsi(contentWritesAfterResize.at(-1)!), boxen('Test'.padEnd(6), {borderStyle: 'round'}) + '\n', ); unmount(); t.is(stdout.listeners('resize').length, 0); }); function ThrottleTestComponent({text}: {readonly text: string}) { return {text}; } function ThrottleCursorTestComponent({text}: {readonly text: string}) { const {setCursorPosition} = useCursor(); setCursorPosition({x: 0, y: 0}); return {text}; } test.serial('throttle renders to maxFps', t => { const clock = FakeTimers.install(); // Controls timers + Date.now() try { const stdout = createStdout(); const {unmount, rerender} = render(, { stdout, maxFps: 1, // 1 Hz => ~1000 ms window }); // Initial render (leading call) t.is(getContentWrites(stdout.write).length, 1); t.is(stripAnsi(getContentWrites(stdout.write)[0]!), 'Hello\n'); // Trigger another render inside the throttle window rerender(); t.is(getContentWrites(stdout.write).length, 1); // Advance 999 ms: still within window, no trailing call yet clock.tick(999); t.is(getContentWrites(stdout.write).length, 1); // Cross the boundary: trailing render fires once clock.tick(1); t.is(getContentWrites(stdout.write).length, 2); t.is(stripAnsi(getContentWrites(stdout.write)[1]!), 'World\n'); unmount(); } finally { clock.uninstall(); } }); test.serial('outputs renderTime when onRender is passed', async t => { const renderTimes: number[] = []; const funcObj = { onRender(metrics: RenderMetrics) { const {renderTime} = metrics; renderTimes.push(renderTime); }, }; const onRenderStub = stub(funcObj, 'onRender').callThrough(); function Test({children}: {readonly children?: ReactNode}) { const [text, setText] = useState('Test'); useInput(input => { setText(input); }); return ( {text} {children} ); } const stdin = createStdin(); const {unmount, rerender} = render(, { onRender: onRenderStub, stdin, }); // Initial render t.is(onRenderStub.callCount, 1); t.true(renderTimes[0] >= 0); // Manual rerender onRenderStub.resetHistory(); rerender( Updated , ); await delay(100); t.is(onRenderStub.callCount, 1); t.true(renderTimes[1] >= 0); // Internal state update via useInput onRenderStub.resetHistory(); emitReadable(stdin, 'a'); await delay(100); t.is(onRenderStub.callCount, 1); t.true(renderTimes[2] >= 0); // Verify all renders were tracked t.is(renderTimes.length, 3); unmount(); }); test.serial('no throttled renders after unmount', t => { const clock = FakeTimers.install(); try { const stdout = createStdout(); const {unmount, rerender} = render(, { stdout, }); t.is(getContentWrites(stdout.write).length, 1); rerender(); rerender(); unmount(); const contentCountAfterUnmount = getContentWrites(stdout.write).length; // Regression test for https://github.com/vadimdemedes/ink/issues/692 clock.tick(1000); t.is(getContentWrites(stdout.write).length, contentCountAfterUnmount); } finally { clock.uninstall(); } }); test.serial('unmount forces pending throttled render', t => { const clock = FakeTimers.install(); try { const stdout = createStdout(); const {unmount, rerender} = render(, { stdout, maxFps: 1, // 1 Hz => ~1000 ms throttle window }); // Initial render (leading call) t.is(getContentWrites(stdout.write).length, 1); t.is(stripAnsi(getContentWrites(stdout.write)[0]!), 'Hello\n'); // Trigger another render inside the throttle window rerender(); // Not rendered yet due to throttling t.is(getContentWrites(stdout.write).length, 1); // Unmount should flush the pending render so the final frame is visible unmount(); // The final frame should have been rendered const allContentWrites = getContentWrites(stdout.write).map((w: string) => stripAnsi(w), ); t.true(allContentWrites.some((call: string) => call.includes('Final'))); } finally { clock.uninstall(); } }); test.serial( 'should reject waitUntilExit when app exits during synchronous render error handling', async t => { const stdout = createStdout(); const {waitUntilExit} = render(, { stdout, patchConsole: false, }); await t.throwsAsync( Promise.race([ waitUntilExit(), delay(500).then(() => { throw new Error('waitUntilExit did not settle'); }), ]), { message: 'Synchronous render error', }, ); }, ); test.serial('waitUntilExit resolves after stdout write callback', async t => { let writeCallbackFired = false; const stdout = new Writable({ write(_chunk, _encoding, callback) { setTimeout(() => { writeCallbackFired = true; callback(); }, 150); }, }) as unknown as NodeJS.WriteStream; stdout.columns = 100; const {unmount, waitUntilExit} = render(Hello, {stdout}); const exitPromise = waitUntilExit(); unmount(); await exitPromise; t.true(writeCallbackFired); }); test.serial( 'createDelayedWriteCallbackStdout delays only the first matching chunk', async t => { let delayCount = 0; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return !isWriteBarrierChunk(chunk); }, onDelayElapsed() { delayCount++; }, delayMs: 80, }); const writeChunk = async (chunk: string | Uint8Array): Promise => new Promise(resolve => { stdout.write(chunk, () => { resolve(); }); }); await writeChunk(''); t.is(delayCount, 0); let didDelayedWriteResolve = false; const delayedWritePromise = (async () => { await writeChunk('Hello'); didDelayedWriteResolve = true; })(); await delay(20); t.false(didDelayedWriteResolve); await delayedWritePromise; t.is(delayCount, 1); let didImmediateWriteResolve = false; const immediateWritePromise = (async () => { await writeChunk('World'); didImmediateWriteResolve = true; })(); await delay(0); t.true(didImmediateWriteResolve); await immediateWritePromise; t.is(delayCount, 1); }, ); test.serial( 'waitUntilRenderFlush resolves after stdout write callback', async t => { let didInitialWriteCallbackFire = false; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return !isWriteBarrierChunk(chunk); }, onDelayElapsed() { didInitialWriteCallbackFire = true; }, }); const {unmount, waitUntilExit, waitUntilRenderFlush} = render( Hello, { stdout, }, ); t.teardown(async () => { unmount(); await waitUntilExit(); }); await waitUntilRenderFlush(); t.true(didInitialWriteCallbackFire); }, ); test.serial( 'waitUntilRenderFlush flushes pending throttled render', async t => { const stdout = createStdout(); const {unmount, rerender, waitUntilExit, waitUntilRenderFlush} = render( , { stdout, maxFps: 1, }, ); t.teardown(async () => { unmount(); await waitUntilExit(); }); t.is(getContentWrites(stdout.write).length, 1); rerender(); t.is(getContentWrites(stdout.write).length, 1); await waitUntilRenderFlush(); t.is(getContentWrites(stdout.write).length, 2); t.is(stripAnsi(getContentWrites(stdout.write)[1]!), 'World\n'); }, ); test.serial( 'waitUntilRenderFlush resolves when stdout is not writable', async t => { const stdout = createStdout(); const {unmount, rerender, waitUntilExit, waitUntilRenderFlush} = render( , { stdout, maxFps: 1, }, ); t.teardown(async () => { unmount(); await waitUntilExit(); }); t.is(getContentWrites(stdout.write).length, 1); rerender(); t.is(getContentWrites(stdout.write).length, 1); (stdout as NodeJS.WriteStream & {writable?: boolean}).writable = false; await waitUntilRenderFlush(); t.is(getContentWrites(stdout.write).length, 1); }, ); test.serial( 'waitUntilRenderFlush waits for rerender write callback', async t => { let didSecondWriteCallbackFire = false; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return ( !isWriteBarrierChunk(chunk) && toRenderedChunk(chunk).includes('World') ); }, onDelayElapsed() { didSecondWriteCallbackFire = true; }, }); const {unmount, rerender, waitUntilExit, waitUntilRenderFlush} = render( Hello, {stdout}, ); t.teardown(async () => { unmount(); await waitUntilExit(); }); await waitUntilRenderFlush(); rerender(World); await waitUntilRenderFlush(); t.true(didSecondWriteCallbackFire); }, ); test.serial( 'waitUntilRenderFlush waits for concurrent rerender commit', async t => { let renderedOutput = ''; const stdout = new Writable({ write( chunk: string | Uint8Array, _encoding: BufferEncoding, callback: (error?: Error) => void, ) { renderedOutput += toRenderedChunk(chunk); callback(); }, }) as unknown as NodeJS.WriteStream; stdout.columns = 100; stdout.isTTY = true; const {unmount, rerender, waitUntilExit, waitUntilRenderFlush} = render( Hello, { stdout, concurrent: true, }, ); t.teardown(async () => { unmount(); await waitUntilExit(); }); await waitUntilRenderFlush(); rerender(World); await waitUntilRenderFlush(); t.true(renderedOutput.includes('World')); }, ); test.serial( 'waitUntilRenderFlush waits for all concurrent waiters on the same rerender', async t => { let didWorldWriteCallbackFire = false; let didAnyWaiterResolveBeforeWorldWriteCallback = false; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return ( !isWriteBarrierChunk(chunk) && toRenderedChunk(chunk).includes('World') ); }, onDelayElapsed() { didWorldWriteCallbackFire = true; }, }); const {unmount, rerender, waitUntilExit, waitUntilRenderFlush} = render( Hello, {stdout}, ); t.teardown(async () => { unmount(); await waitUntilExit(); }); await waitUntilRenderFlush(); rerender(World); const waitForFlush = async () => { await waitUntilRenderFlush(); if (!didWorldWriteCallbackFire) { didAnyWaiterResolveBeforeWorldWriteCallback = true; } }; await Promise.all([waitForFlush(), waitForFlush()]); t.true(didWorldWriteCallbackFire); t.false(didAnyWaiterResolveBeforeWorldWriteCallback); }, ); test.serial( 'useApp waitUntilRenderFlush resolves after the first frame write callback', async t => { let didInitialWriteCallbackFire = false; let didWaitUntilRenderFlushResolve = false; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return !isWriteBarrierChunk(chunk); }, onDelayElapsed() { didInitialWriteCallbackFire = true; }, }); function Test() { const {exit, waitUntilRenderFlush} = useApp(); useEffect(() => { void (async () => { await waitUntilRenderFlush(); didWaitUntilRenderFlushResolve = true; exit(); })(); }, [exit, waitUntilRenderFlush]); return Hello; } const {waitUntilExit} = render(, {stdout}); await waitUntilExit(); t.true(didInitialWriteCallbackFire); t.true(didWaitUntilRenderFlushResolve); }, ); test.serial( 'useApp waitUntilRenderFlush waits for state update frame flush', async t => { let didWorldWriteCallbackFire = false; let didWaitUntilRenderFlushResolve = false; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return ( !isWriteBarrierChunk(chunk) && toRenderedChunk(chunk).includes('World') ); }, onDelayElapsed() { didWorldWriteCallbackFire = true; }, }); function Test() { const {exit, waitUntilRenderFlush} = useApp(); const [text, setText] = useState('Hello'); useEffect(() => { setText('World'); }, []); useEffect(() => { if (text !== 'World') { return; } void (async () => { await waitUntilRenderFlush(); didWaitUntilRenderFlushResolve = true; exit(); })(); }, [exit, text, waitUntilRenderFlush]); return {text}; } const {waitUntilExit} = render(, {stdout}); await waitUntilExit(); t.true(didWorldWriteCallbackFire); t.true(didWaitUntilRenderFlushResolve); }, ); test.serial( 'useApp waitUntilRenderFlush waits for state update queued in same effect tick', async t => { let didWorldWriteCallbackFire = false; let didWaitUntilRenderFlushResolveBeforeWorldWrite = false; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return ( !isWriteBarrierChunk(chunk) && toRenderedChunk(chunk).includes('World') ); }, onDelayElapsed() { didWorldWriteCallbackFire = true; }, }); function Test() { const {exit, waitUntilRenderFlush} = useApp(); const [text, setText] = useState('Hello'); useEffect(() => { void (async () => { setText('World'); await waitUntilRenderFlush(); if (!didWorldWriteCallbackFire) { didWaitUntilRenderFlushResolveBeforeWorldWrite = true; } exit(); })(); }, [exit, waitUntilRenderFlush]); return {text}; } const {waitUntilExit} = render(, { stdout, concurrent: true, }); await waitUntilExit(); t.true(didWorldWriteCallbackFire); t.false(didWaitUntilRenderFlushResolveBeforeWorldWrite); }, ); test.serial('waitUntilRenderFlush resolves after unmount', async t => { const stdout = createStdout(); const {unmount, waitUntilExit, waitUntilRenderFlush} = render( Hello, { stdout, }, ); unmount(); await waitUntilExit(); await waitUntilRenderFlush(); t.pass(); }); test.serial( 'waitUntilRenderFlush waits for unmount write callback', async t => { let didUnmountWriteCallbackFire = false; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return isWriteBarrierChunk(chunk); }, onDelayElapsed() { didUnmountWriteCallbackFire = true; }, }); const {unmount, waitUntilRenderFlush} = render(Hello, { stdout, }); unmount(); await waitUntilRenderFlush(); t.true(didUnmountWriteCallbackFire); }, ); test.serial( 'waitUntilRenderFlush after unmount does not register beforeExit listener', async t => { const stdout = createStdout(); const {unmount, waitUntilRenderFlush} = render(Hello, { stdout, }); const beforeWaitListenerCount = process.listenerCount('beforeExit'); unmount(); await waitUntilRenderFlush(); t.is(process.listenerCount('beforeExit'), beforeWaitListenerCount); }, ); test.serial('waitUntilRenderFlush resolves after exit with error', async t => { const stdout = createStdout(); function Test() { const {exit} = useApp(); useEffect(() => { exit(new Error('boom')); }, []); return Hello; } const {waitUntilExit, waitUntilRenderFlush} = render(, {stdout}); // Verify exit rejects with the error. await t.throwsAsync(waitUntilExit(), {message: 'boom'}); // Flush must resolve (not reject) even after an error exit. await waitUntilRenderFlush(); }); test.serial( 'issue 596: useEffect can run before the first frame write callback', async t => { let didInitialWriteCallbackFire = false; let didUseEffectRun = false; const stdout = createDelayedWriteCallbackStdout({ shouldDelay(chunk) { return !isWriteBarrierChunk(chunk); }, onDelayElapsed() { didInitialWriteCallbackFire = true; }, }); function Test() { useEffect(() => { didUseEffectRun = true; }, []); return Hello; } const {unmount, waitUntilExit} = render(, {stdout}); await delay(20); t.true(didUseEffectRun); t.false(didInitialWriteCallbackFire); unmount(); await waitUntilExit(); t.true(didInitialWriteCallbackFire); }, ); test.serial( 'waitUntilExit resolves first exit value when duplicate exits happen during teardown', async t => { let barrierWriteCallback: (() => void) | undefined; const stdout = new Writable({ write( chunk: string | Uint8Array, _encoding: BufferEncoding, callback: (error?: Error) => void, ) { if (isWriteBarrierChunk(chunk)) { barrierWriteCallback = callback; return; } callback(); }, }) as unknown as NodeJS.WriteStream; stdout.columns = 100; function Test() { const {exit} = useApp(); useEffect(() => { exit('first'); setTimeout(() => { exit('second'); }, 0); }, []); return Hello; } const {waitUntilExit} = render(, {stdout}); const exitPromise = waitUntilExit(); await delay(0); if (!barrierWriteCallback) { t.fail('Expected unmount to queue a write barrier callback'); return; } barrierWriteCallback(); const result = await exitPromise; t.is(result, 'first'); }, ); test.serial( 'waitUntilExit resolves first exit value when exit is re-entered during unmount writes', async t => { let exit: ((errorOrResult?: unknown) => void) | undefined; let shouldReenterExit = false; let didReenterExit = false; const stdout = new Writable({ write(_chunk, _encoding, callback) { if (shouldReenterExit && !didReenterExit && exit) { didReenterExit = true; exit('second'); } callback(); }, }) as unknown as NodeJS.WriteStream; stdout.columns = 100; stdout.isTTY = true; function Test() { const {exit: appExit} = useApp(); useEffect(() => { exit = appExit; shouldReenterExit = true; appExit('first'); }, []); return Hello; } const {waitUntilExit} = render(, {stdout}); const result = await waitUntilExit(); t.true(didReenterExit); t.is(result, 'first'); }, ); test.serial( 'waitUntilExit resolves first exit value when exit is re-entered during unmount writes in debug mode', async t => { let exit: ((errorOrResult?: unknown) => void) | undefined; let shouldReenterExit = false; let didReenterExit = false; const stdout = new Writable({ write(_chunk, _encoding, callback) { if (shouldReenterExit && !didReenterExit && exit) { didReenterExit = true; exit('second'); } callback(); }, }) as unknown as NodeJS.WriteStream; stdout.columns = 100; stdout.isTTY = true; function Test() { const {exit: appExit} = useApp(); useEffect(() => { exit = appExit; shouldReenterExit = true; appExit('first'); }, []); return Hello; } const {waitUntilExit} = render(, {stdout, debug: true}); const result = await waitUntilExit(); t.true(didReenterExit); t.is(result, 'first'); }, ); test.serial( 'waitUntilExit resolves first exit value when exit is re-entered during unmount writes with screen reader', async t => { let exit: ((errorOrResult?: unknown) => void) | undefined; let shouldReenterExit = false; let didReenterExit = false; const stdout = new Writable({ write(_chunk, _encoding, callback) { if (shouldReenterExit && !didReenterExit && exit) { didReenterExit = true; exit('second'); } callback(); }, }) as unknown as NodeJS.WriteStream; stdout.columns = 100; stdout.isTTY = true; function Test() { const {exit: appExit} = useApp(); useEffect(() => { exit = appExit; shouldReenterExit = true; appExit('first'); }, []); return Hello; } const {waitUntilExit} = render(, { stdout, isScreenReaderEnabled: true, patchConsole: false, }); const result = await waitUntilExit(); t.true(didReenterExit); t.is(result, 'first'); }, ); test.serial('exit rejects on cross-realm Error', async t => { const stdout = new PassThrough() as unknown as NodeJS.WriteStream; stdout.columns = 100; const foreignError = vm.runInNewContext(`new Error('boom')`) as Error; function Test() { const {exit} = useApp(); useEffect(() => { setTimeout(() => { exit(foreignError); }, 0); }, []); return Hello; } const {waitUntilExit} = render(, {stdout, patchConsole: false}); await t.throwsAsync(waitUntilExit(), { message: 'boom', }); }); test.serial( 'exit with cross-realm Error rejects after stdout write callback', async t => { let writeCallbackFired = false; let barrierWriteCallbackFired = false; const stdout = new Writable({ write(chunk: string | Uint8Array, _encoding, callback) { setTimeout(() => { writeCallbackFired = true; if (isWriteBarrierChunk(chunk)) { barrierWriteCallbackFired = true; } callback(); }, 150); }, }) as unknown as NodeJS.WriteStream; stdout.columns = 100; const foreignError = vm.runInNewContext(`new Error('boom')`) as Error; function Test() { const {exit} = useApp(); useEffect(() => { setTimeout(() => { exit(foreignError); }, 0); }, []); return Hello; } const {waitUntilExit} = render(, {stdout, patchConsole: false}); await t.throwsAsync(waitUntilExit(), { message: 'boom', }); t.true(writeCallbackFired); t.true(barrierWriteCallbackFired); }, ); test.serial('unmount does not write to ended stdout stream', async t => { const stdout = new PassThrough() as unknown as NodeJS.WriteStream; stdout.columns = 100; const writeErrors: Error[] = []; stdout.on('error', error => { writeErrors.push(error); }); const {unmount, waitUntilExit} = render(Hello, {stdout}); const exitPromise = waitUntilExit(); stdout.end(); unmount(); await exitPromise; await delay(0); t.false( writeErrors.some( error => (error as NodeJS.ErrnoException).code === 'ERR_STREAM_WRITE_AFTER_END', ), ); }); test.serial( 'unmount cancels pending throttled log writes when stdout is ended', t => { const clock = FakeTimers.install(); try { const stdout = new PassThrough() as unknown as NodeJS.WriteStream; stdout.columns = 100; const writeErrors: Error[] = []; stdout.on('error', error => { writeErrors.push(error); }); const {rerender, unmount} = render( , { stdout, maxFps: 1, }, ); rerender(); stdout.end(); unmount(); clock.tick(1000); t.false( writeErrors.some( error => (error as NodeJS.ErrnoException).code === 'ERR_STREAM_WRITE_AFTER_END', ), ); } finally { clock.uninstall(); } }, ); test.serial( 'unmount cancels pending throttled render when stdout is ended', t => { const clock = FakeTimers.install(); try { const baselineStdout = new PassThrough() as unknown as NodeJS.WriteStream; baselineStdout.columns = 100; const baselineApp = render(, { stdout: baselineStdout, maxFps: 1, }); baselineStdout.end(); baselineApp.unmount(); const baselineTimers = clock.countTimers(); clock.runAll(); const stdout = new PassThrough() as unknown as NodeJS.WriteStream; stdout.columns = 100; const {rerender, unmount} = render( , { stdout, maxFps: 1, }, ); rerender(); stdout.end(); unmount(); t.is(clock.countTimers(), baselineTimers); } finally { clock.uninstall(); } }, ); const createTtyStdout = (columns?: number) => { const stdout = createStdout(columns); (stdout as any).isTTY = true; return stdout; }; const withFakeClock = ( run: (clock: ReturnType) => void, ) => { const clock = FakeTimers.install(); try { run(clock); } finally { clock.uninstall(); } }; const captureWrites = (stdout: NodeJS.WriteStream): string[] => { const writes: string[] = []; const originalWrite = stdout.write; (stdout as any).write = (...args: any[]) => { writes.push(args[0] as string); // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call return (originalWrite as any)(...args); }; return writes; }; const assertNoBsuEsuForUnchangedTrailingRerender = ( t: ExecutionContext, element: React.ReactElement, ) => { withFakeClock(clock => { const stdout = createTtyStdout(); const writes = captureWrites(stdout); const {unmount, rerender} = render(element, {stdout, maxFps: 1}); try { t.true(writes.includes(bsu), 'initial render should include bsu'); writes.length = 0; rerender(element); clock.tick(1000); t.false(writes.includes(bsu), 'unchanged rerender should not emit bsu'); t.false(writes.includes(esu), 'unchanged rerender should not emit esu'); } finally { unmount(); } }); }; test.serial('no bsu/esu when output is unchanged', t => { assertNoBsuEsuForUnchangedTrailingRerender( t, , ); }); test.serial('no bsu/esu when output and cursor are unchanged', t => { assertNoBsuEsuForUnchangedTrailingRerender( t, , ); }); test.serial('bsu/esu wraps throttledLog trailing call', t => { withFakeClock(clock => { const stdout = createTtyStdout(); const writes = captureWrites(stdout); const {unmount, rerender} = render(, { stdout, maxFps: 1, }); try { // Leading call writes: bsu, content, esu const leadingWrites = new Set(writes); t.true(leadingWrites.has(bsu), 'leading call should include bsu'); t.true(leadingWrites.has(esu), 'leading call should include esu'); // Trigger a rerender inside the throttle window (will be deferred as trailing) writes.length = 0; rerender(); // No immediate write yet (throttled) const midWrites = [...writes]; t.false( midWrites.some(w => w.includes('World')), 'trailing call should not write immediately', ); // Advance past throttle window to trigger trailing call writes.length = 0; clock.tick(1000); // Trailing call should also be wrapped with bsu/esu t.true(writes.includes(bsu), 'trailing call should include bsu'); t.true(writes.includes(esu), 'trailing call should include esu'); // Verify bsu comes before content and esu comes after const bsuIdx = writes.indexOf(bsu); const esuIdx = writes.indexOf(esu); t.true(bsuIdx < esuIdx, 'bsu should come before esu'); } finally { unmount(); } }); }); ================================================ FILE: test/sanitize-ansi.ts ================================================ import test from 'ava'; import stripAnsi from 'strip-ansi'; import sanitizeAnsi from '../src/sanitize-ansi.js'; test('preserve plain text', t => { t.is(sanitizeAnsi('hello'), 'hello'); }); test('preserve SGR sequences', t => { const output = sanitizeAnsi('A\u001B[38:2::255:100:0mcolor\u001B[0mB'); t.true(output.includes('\u001B[38:2::255:100:0m')); t.is(stripAnsi(output), 'AcolorB'); }); test('preserve OSC hyperlinks', t => { const output = sanitizeAnsi( '\u001B]8;;https://example.com\u001B\\link\u001B]8;;\u001B\\', ); t.true(output.includes('\u001B]8;;https://example.com')); t.is(stripAnsi(output), 'link'); }); test('preserve OSC hyperlinks terminated by C1 ST', t => { const output = sanitizeAnsi( '\u001B]8;;https://example.com\u009Clink\u001B]8;;\u009C', ); t.true(output.includes('\u001B]8;;https://example.com\u009C')); t.is(stripAnsi(output), 'link'); }); test('preserve C1 OSC hyperlinks terminated by C1 ST', t => { const input = '\u009D8;;https://example.com\u009Clink\u009D8;;\u009C'; const output = sanitizeAnsi(input); t.true(output.includes('\u009D8;;https://example.com\u009C')); t.is(output, input); }); test('preserve C1 OSC hyperlinks terminated by ESC ST', t => { const input = '\u009D8;;https://example.com\u001B\\link\u009D8;;\u001B\\'; const output = sanitizeAnsi(input); t.true(output.includes('\u009D8;;https://example.com\u001B\\')); t.is(output, input); }); test('preserve C1 OSC hyperlinks terminated by BEL', t => { const input = '\u009D8;;https://example.com\u0007link\u009D8;;\u0007'; const output = sanitizeAnsi(input); t.true(output.includes('\u009D8;;https://example.com\u0007')); t.is(output, input); }); test('strip non-SGR CSI sequences as complete units', t => { const output = sanitizeAnsi('A\u001B[>4;2mB\u001B[2 qC'); t.false(output.includes('4;2m')); t.false(output.includes(' q')); t.is(stripAnsi(output), 'ABC'); }); test('strip C1 non-SGR CSI sequences as complete units', t => { const output = sanitizeAnsi('A\u009B>4;2mB\u009B2 qC'); t.false(output.includes('4;2m')); t.false(output.includes(' q')); t.is(stripAnsi(output), 'ABC'); }); test('preserve C1 SGR CSI sequences', t => { const output = sanitizeAnsi('A\u009B31mgreen\u009B0mB'); t.true(output.includes('\u009B31m')); t.is(stripAnsi(output), 'AgreenB'); }); test('strip private-parameter m-sequences that are not SGR', t => { const output = sanitizeAnsi('A\u001B[>4;2mB'); t.false(output.includes('\u001B[>4;2m')); t.is(stripAnsi(output), 'AB'); }); test('strip tmux DCS passthrough wrappers with escaped ST payload terminators', t => { const wrappedHyperlinkStart = '\u001BPtmux;\u001B\u001B]8;;https://example.com\u001B\u001B\\\u001B\\'; const wrappedHyperlinkEnd = '\u001BPtmux;\u001B\u001B]8;;\u001B\u001B\\\u001B\\'; const output = sanitizeAnsi( `${wrappedHyperlinkStart}link${wrappedHyperlinkEnd}`, ); t.false(output.includes('tmux;')); t.false(output.includes('\u001BP')); t.is(stripAnsi(output), 'link'); }); test('strip incomplete DCS passthrough sequences to avoid payload leaks', t => { const output = sanitizeAnsi('A\u001BPtmux;\u001Blink'); t.false(output.includes('tmux;')); t.is(stripAnsi(output), 'A'); }); test('strip DCS control strings with BEL in payload until ST terminator', t => { const output = sanitizeAnsi('A\u001BPpayload\u0007still-payload\u001B\\B'); t.false(output.includes('payload')); t.false(output.includes('still-payload')); t.is(stripAnsi(output), 'AB'); }); test('strip ESC SOS control strings as complete units', t => { const output = sanitizeAnsi('A\u001BXpayload\u001B\\B'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'AB'); }); test('strip ESC SOS control strings with C1 ST terminator', t => { const output = sanitizeAnsi('A\u001BXpayload\u009CB'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'AB'); }); test('strip C1 SOS control strings as complete units with C1 ST terminator', t => { const output = sanitizeAnsi('A\u0098payload\u009CB'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'AB'); }); test('strip C1 SOS control strings as complete units with ESC ST terminator', t => { const output = sanitizeAnsi('A\u0098payload\u001B\\B'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'AB'); }); test('strip ESC SOS with BEL terminator as malformed control string', t => { const output = sanitizeAnsi('A\u001BXpayload\u0007B'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'A'); }); test('strip C1 SOS with BEL terminator as malformed control string', t => { const output = sanitizeAnsi('A\u0098payload\u0007B'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'A'); }); test('strip incomplete ESC SOS control strings to avoid payload leaks', t => { const output = sanitizeAnsi('A\u001BXpayload'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'A'); }); test('strip incomplete C1 SOS control strings to avoid payload leaks', t => { const output = sanitizeAnsi('A\u0098payload'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'A'); }); test('strip SOS with escaped ESC in payload until final ST terminator', t => { const output = sanitizeAnsi('A\u001BXfoo\u001B\u001B\\bar\u001B\\B'); t.false(output.includes('foo')); t.false(output.includes('bar')); t.is(stripAnsi(output), 'AB'); }); test('preserve SGR around stripped SOS control strings', t => { const output = sanitizeAnsi('A\u001B[31mR\u001B[0m\u001BXpayload\u001B\\B'); t.true(output.includes('\u001B[31m')); t.true(output.includes('\u001B[0m')); t.false(output.includes('payload')); t.is(stripAnsi(output), 'ARB'); }); test('strip ESC ST sequences', t => { const output = sanitizeAnsi('A\u001B\\B'); t.false(output.includes('\u001B\\')); t.is(stripAnsi(output), 'AB'); }); test('strip malformed ESC control sequences with intermediates and non-final bytes', t => { const output = sanitizeAnsi('A\u001B#\u0007payload'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'A'); }); test('strip incomplete CSI after preserving prior SGR content', t => { const output = sanitizeAnsi('A\u001B[31mB\u001B['); t.true(output.includes('\u001B[31m')); t.is(stripAnsi(output), 'AB'); }); test('strip standalone ST bytes', t => { const output = sanitizeAnsi('A\u009CB'); t.false(output.includes('\u009C')); t.is(stripAnsi(output), 'AB'); }); test('strip standalone C1 control characters', t => { const output = sanitizeAnsi('A\u0085B\u008EC'); t.false(output.includes('\u0085')); t.false(output.includes('\u008E')); t.is(stripAnsi(output), 'ABC'); }); ================================================ FILE: test/screen-reader.tsx ================================================ import test from 'ava'; import React from 'react'; import {Box, Text} from '../src/index.js'; import {renderToString} from './helpers/render-to-string.js'; test('render text for screen readers', t => { const output = renderToString( Not visible to screen readers , { isScreenReaderEnabled: true, }, ); t.is(output, 'Hello World'); }); test('render text for screen readers with aria-hidden', t => { const output = renderToString( Not visible to screen readers , { isScreenReaderEnabled: true, }, ); t.is(output, ''); }); test('render text for screen readers with aria-role', t => { const output = renderToString( Click me , { isScreenReaderEnabled: true, }, ); t.is(output, 'button: Click me'); }); test('render select input for screen readers', t => { const items = ['Red', 'Green', 'Blue']; const output = renderToString( Select a color: {items.map((item, index) => { const isSelected = index === 1; const screenReaderLabel = `${index + 1}. ${item}`; return ( {item} ); })} , { isScreenReaderEnabled: true, }, ); t.is( output, 'list: Select a color:\nlistitem: 1. Red\nlistitem: (selected) 2. Green\nlistitem: 3. Blue', ); }); test('render aria-label only Text for screen readers', t => { const output = renderToString(, { isScreenReaderEnabled: true, }); t.is(output, 'Screen-reader only'); }); test('render aria-label only Box for screen readers', t => { const output = renderToString(, { isScreenReaderEnabled: true, }); t.is(output, 'Screen-reader only'); }); test('omit ANSI styling in screen-reader output', t => { const output = renderToString( {/* eslint-disable-next-line react/jsx-sort-props */} Styled content , { isScreenReaderEnabled: true, }, ); t.is(output, 'Styled content'); }); test('skip nodes with display:none style in screen-reader output', t => { const output = renderToString( Hidden Visible , {isScreenReaderEnabled: true}, ); t.is(output, 'Visible'); }); test('render multiple Text components', t => { const output = renderToString( Hello World , { isScreenReaderEnabled: true, }, ); t.is(output, 'Hello\nWorld'); }); test('render nested Box components with Text', t => { const output = renderToString( Hello World , { isScreenReaderEnabled: true, }, ); t.is(output, 'Hello\nWorld'); }); function NullComponent(): undefined { return undefined; } test('render component that returns null', t => { const output = renderToString( Hello World , { isScreenReaderEnabled: true, }, ); t.is(output, 'Hello\nWorld'); }); test('render with aria-state.busy', t => { const output = renderToString( Loading , { isScreenReaderEnabled: true, }, ); t.is(output, '(busy) Loading'); }); test('render with aria-state.checked', t => { const output = renderToString( Accept terms , { isScreenReaderEnabled: true, }, ); t.is(output, 'checkbox: (checked) Accept terms'); }); test('render with aria-state.disabled', t => { const output = renderToString( Submit , { isScreenReaderEnabled: true, }, ); t.is(output, 'button: (disabled) Submit'); }); test('render with aria-state.expanded', t => { const output = renderToString( Select , { isScreenReaderEnabled: true, }, ); t.is(output, 'combobox: (expanded) Select'); }); test('render with aria-state.multiline', t => { const output = renderToString( Hello , { isScreenReaderEnabled: true, }, ); t.is(output, 'textbox: (multiline) Hello'); }); test('render with aria-state.multiselectable', t => { const output = renderToString( Options , { isScreenReaderEnabled: true, }, ); t.is(output, 'listbox: (multiselectable) Options'); }); test('render with aria-state.readonly', t => { const output = renderToString( Hello , { isScreenReaderEnabled: true, }, ); t.is(output, 'textbox: (readonly) Hello'); }); test('render with aria-state.required', t => { const output = renderToString( Name , { isScreenReaderEnabled: true, }, ); t.is(output, 'textbox: (required) Name'); }); test('render with aria-state.selected', t => { const output = renderToString( Blue , { isScreenReaderEnabled: true, }, ); t.is(output, 'option: (selected) Blue'); }); test('render multi-line text', t => { const output = renderToString( Line 1 Line 2 , { isScreenReaderEnabled: true, }, ); t.is(output, 'Line 1\nLine 2'); }); test('render nested multi-line text', t => { const output = renderToString( Line 1 Line 2 , { isScreenReaderEnabled: true, }, ); t.is(output, 'Line 1\nLine 2'); }); test('render nested row', t => { const output = renderToString( Line 1 Line 2 , { isScreenReaderEnabled: true, }, ); t.is(output, 'Line 1 Line 2'); }); test('render multi-line text with roles', t => { const output = renderToString( Item 1 Item 2 , { isScreenReaderEnabled: true, }, ); t.is(output, 'list: listitem: Item 1\nlistitem: Item 2'); }); test('render listbox with multiselectable options', t => { const output = renderToString( Option 1 Option 2 Option 3 , { isScreenReaderEnabled: true, }, ); t.is( output, 'listbox: (multiselectable) option: (selected) Option 1\noption: Option 2\noption: (selected) Option 3', ); }); ================================================ FILE: test/terminal-resize.tsx ================================================ import process from 'node:process'; import test from 'ava'; import delay from 'delay'; import stripAnsi from 'strip-ansi'; import React from 'react'; import {render, Box, Text, useWindowSize} from '../src/index.js'; import createStdout, {type FakeStdout} from './helpers/create-stdout.js'; const getWriteContents = (stdout: FakeStdout): string[] => stdout .getWrites() .filter(w => !w.startsWith('\u001B[?25') && !w.startsWith('\u001B[?2026')); test.serial( 'useWindowSize returns current terminal dimensions and updates on resize', async t => { const stdout = createStdout(100); (stdout as any).rows = 40; function Test() { const {columns, rows} = useWindowSize(); return ( {columns}x{rows} ); } const {waitUntilRenderFlush} = render(, {stdout}); await waitUntilRenderFlush(); t.true(stripAnsi(getWriteContents(stdout).at(-1)!).includes('100x40')); (stdout as any).columns = 60; (stdout as any).rows = 20; stdout.emit('resize'); await delay(100); t.true(stripAnsi(getWriteContents(stdout).at(-1)!).includes('60x20')); }, ); test.serial('useWindowSize removes resize listener on unmount', async t => { const stdout = createStdout(100); (stdout as any).rows = 24; function Test() { const {columns, rows} = useWindowSize(); return ( {columns}x{rows} ); } const initialListenerCount = stdout.listenerCount('resize'); const {unmount, waitUntilRenderFlush} = render(, {stdout}); await waitUntilRenderFlush(); t.true(stdout.listenerCount('resize') > initialListenerCount); unmount(); t.is(stdout.listenerCount('resize'), initialListenerCount); }); test.serial( 'useWindowSize does not crash when resize fires after unmount', async t => { const stdout = createStdout(100); (stdout as any).rows = 24; function Test() { const {columns, rows} = useWindowSize(); return ( {columns}x{rows} ); } const {unmount, waitUntilRenderFlush} = render(, {stdout}); await waitUntilRenderFlush(); unmount(); stdout.emit('resize'); await delay(50); t.pass(); }, ); test.serial( 'useWindowSize falls back to a positive column count when stdout.columns is 0', async t => { const stdout = createStdout(0); let capturedColumns = -1; function Test() { const {columns} = useWindowSize(); capturedColumns = columns; return {columns}; } const {waitUntilRenderFlush} = render(, {stdout}); await waitUntilRenderFlush(); t.true(capturedColumns > 0); }, ); test.serial( 'useWindowSize falls back to terminal-size rows when stdout.rows is missing', async t => { const stdout = createStdout(0); let capturedRows = -1; const originalColumns = process.env.COLUMNS; const originalLines = process.env.LINES; const originalProcessStdoutColumns = process.stdout.columns; const originalProcessStdoutRows = process.stdout.rows; const originalProcessStderrColumns = process.stderr.columns; const originalProcessStderrRows = process.stderr.rows; t.teardown(() => { process.env.COLUMNS = originalColumns; process.env.LINES = originalLines; process.stdout.columns = originalProcessStdoutColumns; process.stdout.rows = originalProcessStdoutRows; process.stderr.columns = originalProcessStderrColumns; process.stderr.rows = originalProcessStderrRows; }); process.env.COLUMNS = '123'; process.env.LINES = '45'; process.stdout.columns = 0; process.stdout.rows = 0; process.stderr.columns = 0; process.stderr.rows = 0; delete (stdout as any).rows; function Test() { const {rows} = useWindowSize(); capturedRows = rows; return {rows}; } const {waitUntilRenderFlush} = render(, {stdout}); await waitUntilRenderFlush(); t.is(capturedRows, 45); }, ); test.serial('clear screen when terminal width decreases', async t => { const stdout = createStdout(100); function Test() { return ( Hello World ); } render(, {stdout}); const initialOutput = stripAnsi(getWriteContents(stdout)[0]!); t.true(initialOutput.includes('Hello World')); t.true(initialOutput.includes('╭')); // Box border // Decrease width - should trigger clear and rerender stdout.columns = 50; stdout.emit('resize'); await delay(100); // Verify the output was updated for smaller width const lastOutput = stripAnsi(getWriteContents(stdout).at(-1)!); t.true(lastOutput.includes('Hello World')); t.true(lastOutput.includes('╭')); // Box border t.not(initialOutput, lastOutput); // Output should change due to width }); test.serial('no screen clear when terminal width increases', async t => { const stdout = createStdout(50); function Test() { return ( Test ); } render(, {stdout}); const initialOutput = getWriteContents(stdout)[0]!; // Increase width - should rerender but not clear stdout.columns = 100; stdout.emit('resize'); await delay(100); const lastOutput = getWriteContents(stdout).at(-1)!; // When increasing width, we don't clear, so we should see eraseLines used for incremental update // But when decreasing, the clear() is called which also uses eraseLines // The key difference: decreasing width triggers an explicit clear before render t.not(stripAnsi(initialOutput), stripAnsi(lastOutput)); t.true(stripAnsi(lastOutput).includes('Test')); }); test.serial( 'consecutive width decreases trigger screen clear each time', async t => { const stdout = createStdout(100); function Test() { return ( Content ); } render(, {stdout}); const initialOutput = stripAnsi(getWriteContents(stdout)[0]!); // First decrease stdout.columns = 80; stdout.emit('resize'); await delay(100); const afterFirstDecrease = stripAnsi(getWriteContents(stdout).at(-1)!); t.not(initialOutput, afterFirstDecrease); t.true(afterFirstDecrease.includes('Content')); // Second decrease stdout.columns = 60; stdout.emit('resize'); await delay(100); const afterSecondDecrease = stripAnsi(getWriteContents(stdout).at(-1)!); t.not(afterFirstDecrease, afterSecondDecrease); t.true(afterSecondDecrease.includes('Content')); }, ); test.serial('width decrease clears lastOutput to force rerender', async t => { const stdout = createStdout(100); function Test() { return ( Test Content ); } const {rerender} = render(, {stdout}); const initialOutput = stripAnsi(getWriteContents(stdout)[0]!); // Decrease width - with a border, this will definitely change the output stdout.columns = 50; stdout.emit('resize'); await delay(100); const afterResizeOutput = stripAnsi(getWriteContents(stdout).at(-1)!); // Outputs should be different because the border width changed t.not(initialOutput, afterResizeOutput); t.true(afterResizeOutput.includes('Test Content')); // Now try to rerender with a different component rerender( Updated Content , ); await delay(100); // Verify content was updated t.true( stripAnsi(getWriteContents(stdout).at(-1)!).includes('Updated Content'), ); }); ================================================ FILE: test/text-width.tsx ================================================ import React from 'react'; import test from 'ava'; import stripAnsi from 'strip-ansi'; import {Box, Text} from '../src/index.js'; import {renderToString} from './helpers/render-to-string.js'; test('wide characters do not add extra space inside fixed-width Box', t => { const output = renderToString( 🍔 | | , ); const lines = output.split('\n'); t.is(lines.length, 2); t.is(lines[0], '🍔|'); t.is(lines[1], '⏳|'); }); test('CJK characters occupy correct width in fixed-width Box', t => { const output = renderToString( 你好 | , ); t.is(output, '你好|'); }); test('mixed ASCII and wide characters align correctly', t => { const output = renderToString( ab🍔cd | abcdef | , ); const lines = output.split('\n'); t.is(lines.length, 2); t.is(lines[0], 'ab🍔cd|'); t.is(lines[1], 'abcdef|'); }); test('ANSI styled text does not affect layout width', t => { const output = renderToString( hello | , ); const stripped = stripAnsi(output); t.is(stripped, 'hello|'); }); test('empty Text does not affect sibling layout', t => { const output = renderToString( hello , ); t.is(output, 'hello'); }); ================================================ FILE: test/text.tsx ================================================ import React from 'react'; import test from 'ava'; import chalk from 'chalk'; import stripAnsi from 'strip-ansi'; import {render, Box, Text} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; import createStdout from './helpers/create-stdout.js'; import {renderAsync} from './helpers/test-renderer.js'; const renderText = (text: string): string => renderToString( {text} , ); test(' with undefined children', t => { const output = renderToString(); t.is(output, ''); }); test(' with null children', t => { const output = renderToString({null}); t.is(output, ''); }); test('text with standard color', t => { const output = renderToString(Test); t.is(output, chalk.green('Test')); }); test('text with dim+bold', t => { const originalLevel = chalk.level; chalk.level = 3; t.teardown(() => { chalk.level = originalLevel; }); const output = renderToString( Test , ); t.is(stripAnsi(output), 'Test'); t.not(output, 'Test'); // Ensure ANSI codes are present }); test('text with dimmed color', t => { const output = renderToString( Test , ); t.is(output, chalk.green.dim('Test')); }); test('text with hex color', t => { const output = renderToString(Test); t.is(output, chalk.hex('#FF8800')('Test')); }); test('text with rgb color', t => { const output = renderToString(Test); t.is(output, chalk.rgb(255, 136, 0)('Test')); }); test('text with ansi256 color', t => { const output = renderToString(Test); t.is(output, chalk.ansi256(194)('Test')); }); test('text with standard background color', t => { const output = renderToString(Test); t.is(output, chalk.bgGreen('Test')); }); test('text with hex background color', t => { const output = renderToString(Test); t.is(output, chalk.bgHex('#FF8800')('Test')); }); test('text with rgb background color', t => { const output = renderToString( Test, ); t.is(output, chalk.bgRgb(255, 136, 0)('Test')); }); test('text with ansi256 background color', t => { const output = renderToString( Test, ); t.is(output, chalk.bgAnsi256(194)('Test')); }); test('text with inversion', t => { const output = renderToString(Test); t.is(output, chalk.inverse('Test')); }); // See https://github.com/vadimdemedes/ink/issues/867 test('text with empty-to-nonempty sibling does not wrap', t => { function Test({show}: {readonly show?: boolean}) { return ( {show ? 'x' : ''} {'hello'} ); } const stdout = createStdout(); const {rerender} = render(, {stdout, debug: true}); t.is((stdout.write as any).lastCall.args[0], 'hello'); rerender(); t.is((stdout.write as any).lastCall.args[0], 'xhello'); }); test('remeasure text when text is changed', t => { function Test({add}: {readonly add?: boolean}) { return ( {add ? 'abcx' : 'abc'} ); } const stdout = createStdout(); const {rerender} = render(, {stdout, debug: true}); t.is((stdout.write as any).lastCall.args[0], 'abc'); rerender(); t.is((stdout.write as any).lastCall.args[0], 'abcx'); }); test('remeasure text when text nodes are changed', t => { function Test({add}: {readonly add?: boolean}) { return ( abc {add ? x : null} ); } const stdout = createStdout(); const {rerender} = render(, {stdout, debug: true}); t.is((stdout.write as any).lastCall.args[0], 'abc'); rerender(); t.is((stdout.write as any).lastCall.args[0], 'abcx'); }); // See https://github.com/vadimdemedes/ink/issues/743 // Without the fix, the output was ''. test('text with content "constructor" wraps correctly', t => { const output = renderToString(constructor); t.is(output, 'constructor'); }); // See https://github.com/vadimdemedes/ink/issues/362 test('strip ANSI cursor movement sequences from text', t => { // \x1b[1A = cursor up, \x1b[2K = clear line, \x1b[1B = cursor down // \x1b[32m = green (SGR, preserved), \x1b[0m = reset (SGR, preserved) const input = '\u001B[1A\u001B[2KStarting client ... \u001B[32mdone\u001B[0m\u001B[1B'; const output = renderToString( {input} , ); t.false(output.includes('\u001B[1A')); t.false(output.includes('\u001B[2K')); t.false(output.includes('\u001B[1B')); t.is(stripAnsi(output), 'Starting client ... done'); }); test('strip ANSI cursor position and erase sequences from text', t => { const output = renderToString( {'Hello\u001B[5;10HWorld\u001B[2J!'} , ); t.false(output.includes('\u001B[5;10H')); t.false(output.includes('\u001B[2J')); t.is(stripAnsi(output), 'HelloWorld!'); }); test('preserve SGR color sequences in text', t => { const output = renderToString( {'\u001B[32mgreen\u001B[0m normal'} , ); t.true(output.includes('\u001B[')); t.is(stripAnsi(output), 'green normal'); }); test('preserve OSC hyperlink sequences in text', t => { const output = renderText( '\u001B]8;;https://example.com\u0007link\u001B]8;;\u0007', ); t.true(output.includes('\u001B]8;;')); t.is(stripAnsi(output), 'link'); }); test('preserve OSC hyperlink sequences with ST terminator in text', t => { const output = renderText( '\u001B]8;;https://example.com\u001B\\link\u001B]8;;\u001B\\', ); t.true(output.includes('\u001B]8;;')); t.true(output.includes('\u001B\\')); t.is(stripAnsi(output), 'link'); }); test('preserve C1 OSC sequences in text', t => { const input = '\u009D8;;https://example.com\u0007link\u009D8;;\u0007'; const output = renderText(input); t.true(output.includes('\u009D8;;https://example.com')); t.true(output.includes('\u009D8;;\u0007')); t.is(output, input); }); test('preserve C1 OSC hyperlink sequences with ST terminator in text', t => { const input = '\u009D8;;https://example.com\u001B\\link\u009D8;;\u001B\\'; const output = renderText(input); t.true(output.includes('\u009D8;;https://example.com')); t.true(output.includes('\u001B\\')); t.is(output, input); }); test('preserve SGR sequences with colon parameters', t => { const output = renderText('A\u001B[38:2::255:100:0mcolor\u001B[0mB'); t.true(output.includes('\u001B[38:2::255:100:0m')); t.is(stripAnsi(output), 'AcolorB'); }); test('strip complete non-SGR CSI sequences without leaking parameters', t => { const input = 'A\u001B[>4;2mB\u001B[2 qC'; const output = renderText(input); t.false(output.includes('4;2m')); t.false(output.includes(' q')); t.is(stripAnsi(output), 'ABC'); }); test('strip complete C1 non-SGR CSI sequences without leaking parameters', t => { const output = renderText('A\u009B>4;2mB\u009B2 qC'); t.false(output.includes('4;2m')); t.false(output.includes(' q')); t.is(stripAnsi(output), 'ABC'); }); test('strip complete ESC control sequences with intermediates', t => { const output = renderText('A\u001B#8B\u001BcC'); t.false(output.includes('\u001B#8')); t.false(output.includes('\u001Bc')); t.is(stripAnsi(output), 'ABC'); }); test('strip tmux DCS passthrough wrappers without leaking payload', t => { const wrappedHyperlinkStart = '\u001BPtmux;\u001B\u001B]8;;https://example.com\u0007\u001B\\'; const wrappedHyperlinkEnd = '\u001BPtmux;\u001B\u001B]8;;\u0007\u001B\\'; const output = renderText( `${wrappedHyperlinkStart}link${wrappedHyperlinkEnd}`, ); t.false(output.includes('tmux;')); t.false(output.includes('\u001BP')); t.false(output.includes('\u001B\\')); t.is(stripAnsi(output), 'link'); }); test('strip tmux DCS passthrough wrappers with ST-terminated OSC payload', t => { const wrappedHyperlinkStart = '\u001BPtmux;\u001B\u001B]8;;https://example.com\u001B\u001B\\\u001B\\'; const wrappedHyperlinkEnd = '\u001BPtmux;\u001B\u001B]8;;\u001B\u001B\\\u001B\\'; const output = renderText( `${wrappedHyperlinkStart}link${wrappedHyperlinkEnd}`, ); t.false(output.includes('tmux;')); t.false(output.includes('\u001B\\')); t.is(stripAnsi(output), 'link'); }); test('strip C1 DCS control strings as complete units', t => { const output = renderText('A\u0090payload\u001B\\B\u0090payload\u009CC'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'ABC'); }); test('strip PM and APC control strings as complete units', t => { const output = renderText( 'A\u001B^pm-payload\u001B\\B\u001B_apc-payload\u001B\\C', ); t.false(output.includes('pm-payload')); t.false(output.includes('apc-payload')); t.is(stripAnsi(output), 'ABC'); }); test('strip C1 PM and APC control strings as complete units', t => { const output = renderText('A\u009Epm-payload\u009CB\u009Fapc-payload\u009CC'); t.false(output.includes('pm-payload')); t.false(output.includes('apc-payload')); t.is(stripAnsi(output), 'ABC'); }); test('strip ESC SOS control strings as complete units', t => { const output = renderText('A\u001BXpayload\u001B\\B'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'AB'); }); test('strip C1 SOS control strings as complete units', t => { const output = renderText('A\u0098payload\u001B\\B\u0098payload\u009CC'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'ABC'); }); test('strip malformed SOS control strings to avoid payload leaks', t => { const output = renderText('A\u001BXpayload\u0007B\u0098payload'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'A'); }); test('preserve SGR sequences around stripped SOS control strings', t => { const output = renderText('A\u001B[32mgreen\u001B[0m\u001BXpayload\u001B\\B'); t.true(output.includes('\u001B[')); t.false(output.includes('payload')); t.is(stripAnsi(output), 'AgreenB'); }); test('strip tmux DCS passthrough containing BEL until the final ST terminator', t => { const input = 'A\u001BPtmux;\u001B\u001B]0;title\u0007\u001B\\B'; const output = renderText(input); t.false(output.includes('tmux;')); t.false(output.includes('title')); t.is(stripAnsi(output), 'AB'); }); test('strip incomplete DCS passthrough sequences to avoid payload leaks', t => { const incompleteSequence = '\u001BPtmux;\u001B'; const output = renderText(`${incompleteSequence}link`); t.false(output.includes('tmux;')); t.is(stripAnsi(output), ''); }); test('strip incomplete C1 DCS control strings to avoid payload leaks', t => { const output = renderText('A\u0090payload'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'A'); }); test('strip incomplete OSC control strings to avoid payload leaks', t => { const output = renderText('A\u001B]8;;https://example.comlink'); t.false(output.includes('https://example.com')); t.is(stripAnsi(output), 'A'); }); test('strip incomplete C1 OSC control strings to avoid payload leaks', t => { const output = renderText('A\u009D8;;https://example.comlink'); t.false(output.includes('https://example.com')); t.is(stripAnsi(output), 'A'); }); test('strip incomplete ESC control sequences with intermediates to avoid payload leaks', t => { const output = renderText('A\u001B#'); t.false(output.includes('\u001B#')); t.is(stripAnsi(output), 'A'); }); test('strip malformed ESC control sequences with intermediates and non-final bytes', t => { const output = renderText('A\u001B#\u0007payload'); t.false(output.includes('payload')); t.is(stripAnsi(output), 'A'); }); test('strip standalone ST bytes from text output', t => { const output = renderText('A\u009CB'); t.false(output.includes('\u009C')); t.is(stripAnsi(output), 'AB'); }); test('strip standalone C1 control characters from text output', t => { const output = renderText('A\u0085B\u008EC'); t.false(output.includes('\u0085')); t.false(output.includes('\u008E')); t.is(stripAnsi(output), 'ABC'); }); // Concurrent mode tests test(' with undefined children - concurrent', async t => { const output = await renderToStringAsync(); t.is(output, ''); }); test(' with null children - concurrent', async t => { const output = await renderToStringAsync({null}); t.is(output, ''); }); test('text with standard color - concurrent', async t => { const output = await renderToStringAsync(Test); t.is(output, chalk.green('Test')); }); test('text with dim+bold - concurrent', async t => { const originalLevel = chalk.level; chalk.level = 3; t.teardown(() => { chalk.level = originalLevel; }); const output = await renderToStringAsync( Test , ); t.is(stripAnsi(output), 'Test'); t.not(output, 'Test'); // Ensure ANSI codes are present }); test('text with hex color - concurrent', async t => { const output = await renderToStringAsync(Test); t.is(output, chalk.hex('#FF8800')('Test')); }); test('text with inversion - concurrent', async t => { const output = await renderToStringAsync(Test); t.is(output, chalk.inverse('Test')); }); test('remeasure text when text is changed - concurrent', async t => { function Test({add}: {readonly add?: boolean}) { return ( {add ? 'abcx' : 'abc'} ); } const {getOutput, rerenderAsync} = await renderAsync(); t.is(getOutput(), 'abc'); await rerenderAsync(); t.is(getOutput(), 'abcx'); }); test('remeasure text when text nodes are changed - concurrent', async t => { function Test({add}: {readonly add?: boolean}) { return ( abc {add ? x : null} ); } const {getOutput, rerenderAsync} = await renderAsync(); t.is(getOutput(), 'abc'); await rerenderAsync(); t.is(getOutput(), 'abcx'); }); ================================================ FILE: test/tsconfig.json ================================================ { "extends": "../tsconfig.json", "include": ["."] } ================================================ FILE: test/use-box-metrics.tsx ================================================ import React, {useRef, useState} from 'react'; import test from 'ava'; import delay from 'delay'; import stripAnsi from 'strip-ansi'; import { Box, Text, render, useBoxMetrics, type DOMElement, } from '../src/index.js'; import createStdout from './helpers/create-stdout.js'; test('returns correct size on first render', async t => { const stdout = createStdout(100); function Test() { const ref = useRef(null); const {width, height} = useBoxMetrics(ref); return ( {width}x{height} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); // Width fills terminal (100); single-line text renders as height 1 t.true(stripAnsi(stdout.get()).includes('100x1')); }); test('returns correct position', async t => { const stdout = createStdout(100); function Test() { const ref = useRef(null); const {left, top} = useBoxMetrics(ref); return ( first line {left},{top} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); // MarginLeft=5 → left=5; second row → top=1 t.true(stripAnsi(stdout.get()).includes('5,1')); }); test('updates when terminal is resized', async t => { const stdout = createStdout(100); function Test() { const ref = useRef(null); const {width} = useBoxMetrics(ref); return ( Width: {width} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Width: 100')); (stdout as any).columns = 60; stdout.emit('resize'); await delay(200); t.true(stripAnsi(stdout.get()).includes('Width: 60')); }); test('uses latest tracked ref when terminal is resized', async t => { const stdout = createStdout(100); let trackSecondRef!: () => void; function Test() { const firstRef = useRef(null); const secondRef = useRef(null); const [isSecondRefTracked, setIsSecondRefTracked] = useState(false); const trackedRef = isSecondRefTracked ? secondRef : firstRef; const {height} = useBoxMetrics(trackedRef); trackSecondRef = () => { setIsSecondRefTracked(true); }; return ( short ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 Tracked height: {height} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Tracked height: 1')); trackSecondRef(); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Tracked height: 1')); (stdout as any).columns = 20; stdout.emit('resize'); await delay(200); t.true(stripAnsi(stdout.get()).includes('Tracked height: 4')); }); test('updates when sibling content changes', async t => { const stdout = createStdout(100); let externalSetSiblingText!: (text: string) => void; function Test() { const ref = useRef(null); const [siblingText, setSiblingText] = useState('short'); const {height} = useBoxMetrics(ref); externalSetSiblingText = setSiblingText; return ( {siblingText} Height: {height} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Height: 1')); externalSetSiblingText('line 1\nline 2\nline 3'); await delay(50); t.true(stripAnsi(stdout.get()).includes('Height: 3')); }); test('updates when sibling content changes but tracked component is memoized', async t => { const stdout = createStdout(100); let externalSetSiblingText!: (text: string) => void; const MemoizedTrackedBox = React.memo(function () { const ref = useRef(null); const {top} = useBoxMetrics(ref); return ( Top: {top} ); }); function Test() { const [siblingText, setSiblingText] = useState('line 1'); externalSetSiblingText = setSiblingText; return ( {siblingText} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Top: 1')); externalSetSiblingText('line 1\nline 2\nline 3'); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Top: 3')); }); test('updates when tracked ref attaches after initial render and component is memoized', async t => { const stdout = createStdout(100); let externalSetSiblingText!: (text: string) => void; let externalSetIsTrackedElementMounted!: (value: boolean) => void; const MemoizedTrackedBox = React.memo(function ({ isTrackedElementMounted, }: { readonly isTrackedElementMounted: boolean; }) { const ref = useRef(null); const {top} = useBoxMetrics(ref); return isTrackedElementMounted ? ( Top: {top} ) : ( Top: {top} ); }); function Test() { const [siblingText, setSiblingText] = useState('line 1'); const [isTrackedElementMounted, setIsTrackedElementMounted] = useState(false); externalSetSiblingText = setSiblingText; externalSetIsTrackedElementMounted = setIsTrackedElementMounted; return ( {siblingText} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Top: 0')); externalSetIsTrackedElementMounted(true); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Top: 1')); externalSetSiblingText('line 1\nline 2\nline 3'); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Top: 3')); }); test('does not trigger extra re-renders when layout is unchanged', async t => { const stdout = createStdout(100); let renderCount = 0; function Test() { const ref = useRef(null); useBoxMetrics(ref); renderCount++; return ( Hello ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(100); // Renders settle at 2: initial render (layout all zeros) → setLayout triggers // re-render (layout measured) → bail-out prevents any further renders. t.true(renderCount >= 2 && renderCount <= 3); }); function SimpleBox() { const ref = useRef(null); useBoxMetrics(ref); return ( Hello ); } test.serial('removes resize listener on unmount', async t => { const stdout = createStdout(100); const initialListenerCount = stdout.listenerCount('resize'); const {unmount, waitUntilRenderFlush} = render(, {stdout}); await waitUntilRenderFlush(); t.true(stdout.listenerCount('resize') > initialListenerCount); unmount(); t.is(stdout.listenerCount('resize'), initialListenerCount); }); test.serial('does not crash when resize fires after unmount', async t => { const stdout = createStdout(100); const {unmount, waitUntilRenderFlush} = render(, {stdout}); await waitUntilRenderFlush(); unmount(); stdout.emit('resize'); await delay(50); t.pass(); }); test('returns zeros when ref is not attached', async t => { const stdout = createStdout(100); function Test() { const ref = useRef(null); const {width, height, left, top, hasMeasured} = useBoxMetrics(ref); return ( {width},{height},{left},{top},{String(hasMeasured)} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('0,0,0,0,false')); }); test('hasMeasured becomes true when tracked element is mounted on initial render', async t => { const stdout = createStdout(100); function Test() { const ref = useRef(null); const {hasMeasured} = useBoxMetrics(ref); return ( Has measured: {String(hasMeasured)} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Has measured: true')); }); test('hasMeasured resets when tracked ref switches to a detached element', async t => { const stdout = createStdout(100); let trackSecondRef!: () => void; let mountSecondRef!: () => void; function Test() { const firstRef = useRef(null); const secondRef = useRef(null); const [isSecondRefTracked, setIsSecondRefTracked] = useState(false); const [isSecondRefMounted, setIsSecondRefMounted] = useState(false); const trackedRef = isSecondRefTracked ? secondRef : firstRef; const {hasMeasured} = useBoxMetrics(trackedRef); trackSecondRef = () => { setIsSecondRefTracked(true); }; mountSecondRef = () => { setIsSecondRefMounted(true); }; return ( First {isSecondRefMounted ? ( Second ) : undefined} Has measured: {String(hasMeasured)} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Has measured: true')); trackSecondRef(); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Has measured: false')); mountSecondRef(); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Has measured: true')); }); test('hasMeasured becomes true after the tracked element is measured', async t => { const stdout = createStdout(100); let mountTrackedElement!: () => void; function Test() { const ref = useRef(null); const [isTrackedElementMounted, setIsTrackedElementMounted] = useState(false); const {hasMeasured} = useBoxMetrics(ref); mountTrackedElement = () => { setIsTrackedElementMounted(true); }; return ( {isTrackedElementMounted ? ( Tracked ) : undefined} Has measured: {String(hasMeasured)} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Has measured: false')); mountTrackedElement(); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Has measured: true')); }); test('resets metrics when tracked element unmounts', async t => { const stdout = createStdout(100); let unmountTrackedElement!: () => void; function Test() { const ref = useRef(null); const [isTrackedElementMounted, setIsTrackedElementMounted] = useState(true); const {width, height, left, top, hasMeasured} = useBoxMetrics(ref); unmountTrackedElement = () => { setIsTrackedElementMounted(false); }; return ( {isTrackedElementMounted ? ( 1234567890 ) : undefined} Metrics: {width},{height},{left},{top},{String(hasMeasured)} ); } const {waitUntilRenderFlush} = render(, {stdout, debug: true}); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Metrics: 10,1,0,0,true')); unmountTrackedElement(); await waitUntilRenderFlush(); await delay(50); t.true(stripAnsi(stdout.get()).includes('Metrics: 0,0,0,0,false')); }); ================================================ FILE: test/width-height.tsx ================================================ import React from 'react'; import test from 'ava'; import {Box, Text, render} from '../src/index.js'; import { renderToString, renderToStringAsync, } from './helpers/render-to-string.js'; import createStdout from './helpers/create-stdout.js'; test('set width', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('set width in percent', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('set min width', t => { const smallerOutput = renderToString( A B , ); t.is(smallerOutput, 'A B'); const largerOutput = renderToString( AAAAA B , ); t.is(largerOutput, 'AAAAAB'); }); test.failing('set min width in percent', t => { const output = renderToString( A B , ); t.is(output, 'A B'); }); test('set height', t => { const output = renderToString( A B , ); t.is(output, 'AB\n\n\n'); }); test('set height in percent', t => { const output = renderToString( A B , ); t.is(output, 'A\n\n\nB\n\n'); }); test('cut text over the set height', t => { const output = renderToString( AAAABBBBCCCC , {columns: 4}, ); t.is(output, 'AAAA\nBBBB'); }); test('set min height', t => { const smallerOutput = renderToString( A , ); t.is(smallerOutput, 'A\n\n\n'); const largerOutput = renderToString( A , ); t.is(largerOutput, 'A\n\n\n'); }); test('set min height in percent', t => { const output = renderToString( A B , ); t.is(output, 'A\n\n\nB\n\n'); }); test('set max width', t => { const constrainedOutput = renderToString( AAAAA B , {columns: 10}, ); t.is(constrainedOutput, 'AAAB\nAA'); const unconstrainedOutput = renderToString( AAA B , ); t.is(unconstrainedOutput, 'AAAB'); }); test('clears maxWidth on rerender', t => { const stdout = createStdout(); function Test({maxWidth}: {readonly maxWidth?: number}) { return ( AAAAA B ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], 'AAAB\nAA'); rerender(); t.is(stdout.write.lastCall.args[0], 'AAAAAB'); }); test('set max height', t => { const constrainedOutput = renderToString( A , ); t.is(constrainedOutput, 'A\n'); const unconstrainedOutput = renderToString( A , ); t.is(unconstrainedOutput, 'A'); }); test('clears maxHeight on rerender', t => { const stdout = createStdout(); function Test({maxHeight}: {readonly maxHeight?: number}) { return ( A ); } const {rerender} = render(, { stdout, debug: true, }); t.is(stdout.write.lastCall.args[0], 'A\n'); rerender(); t.is(stdout.write.lastCall.args[0], 'A\n\n\n'); }); test('set aspect ratio with width', t => { const output = renderToString( X Y , ); t.is(output, '┌──────┐\n│X │\n│ │\n└──────┘\nY'); }); test('set aspect ratio with height', t => { const output = renderToString( X Y , ); t.is(output, '┌────┐\n│X │\n└────┘\nY'); }); test('set aspect ratio with width and height', t => { const output = renderToString( X Y , ); t.is(output, '┌────┐\n│X │\n└────┘\nY'); }); test('set aspect ratio with maxHeight constraint', t => { const output = renderToString( X Y , ); t.is(output, '┌────┐\n│X │\n└────┘\nY'); }); test('clears aspectRatio on rerender', t => { const stdout = createStdout(); function Test({aspectRatio}: {readonly aspectRatio?: number}) { return ( X Y ); } const {rerender} = render(, { stdout, debug: true, }); t.is( stdout.write.lastCall.args[0], '┌──────┐\n│X │\n│ │\n└──────┘\nY', ); rerender(); t.is(stdout.write.lastCall.args[0], '┌──────┐\n│X │\n└──────┘\nY'); }); test.failing('set max width in percent', t => { const output = renderToString( AAAAAAAAAA B , ); t.is(output, 'AAAAAB'); }); test('set max height in percent', t => { const output = renderToString( A B , ); t.is(output, 'A\n\n\nB\n\n'); }); // Concurrent mode tests test('set width - concurrent', async t => { const output = await renderToStringAsync( A B , ); t.is(output, 'A B'); }); test('set height - concurrent', async t => { const output = await renderToStringAsync( A B , ); t.is(output, 'AB\n\n\n'); }); ================================================ FILE: test/write-synchronized.tsx ================================================ import EventEmitter from 'node:events'; import test from 'ava'; import isInCi from 'is-in-ci'; import {bsu, esu, shouldSynchronize} from '../src/write-synchronized.js'; const createStream = ({tty = false} = {}) => { const stream = new EventEmitter() as unknown as NodeJS.WriteStream; if (tty) { stream.isTTY = true; } return stream; }; for (const [sequenceName, sequence, expected] of [ ['bsu', bsu, '\u001B[?2026h'], ['esu', esu, '\u001B[?2026l'], ] as const) { test(`${sequenceName} is the expected synchronized update sequence`, t => { t.is(sequence, expected); }); } test('shouldSynchronize returns true for interactive TTY stream', t => { const stream = createStream({tty: true}); t.true(shouldSynchronize(stream, true)); }); test('shouldSynchronize returns false for non-interactive TTY stream', t => { const stream = createStream({tty: true}); t.false(shouldSynchronize(stream, false)); }); test('shouldSynchronize returns false for non-TTY stream', t => { const stream = createStream({tty: false}); t.false(shouldSynchronize(stream, true)); }); test('shouldSynchronize uses CI detection when interactive is not specified', t => { const ttyStream = createStream({tty: true}); // When interactive is omitted, shouldSynchronize falls back to is-in-ci. // In CI the result is false (non-interactive by design); outside CI it's true. if (isInCi) { t.false(shouldSynchronize(ttyStream)); } else { t.true(shouldSynchronize(ttyStream)); } }); test('shouldSynchronize returns false for non-TTY stream when interactive is not specified', t => { const stream = createStream({tty: false}); t.false(shouldSynchronize(stream)); }); ================================================ FILE: tsconfig.json ================================================ { "extends": "@sindresorhus/tsconfig", "compilerOptions": { "outDir": "build", "lib": [ "DOM", "DOM.Iterable", "ES2023" ], "sourceMap": true, "jsx": "react", "isolatedModules": true }, "include": ["src"] } ================================================ FILE: xo.config.ts ================================================ import {type FlatXoConfig} from 'xo'; const xoConfig: FlatXoConfig = [ { ignores: ['src/parse-keypress.ts'], }, { react: true, prettier: true, semicolon: true, rules: { 'react/no-unescaped-entities': 'off', 'react/state-in-constructor': 'off', 'react/jsx-indent': 'off', 'react/prop-types': 'off', 'unicorn/import-index': 'off', 'import-x/no-useless-path-segments': 'off', 'react-hooks/exhaustive-deps': 'off', complexity: 'off', }, }, { files: ['src/**/*.{ts,tsx}', 'test/**/*.{ts,tsx}'], rules: { 'no-unused-expressions': 'off', camelcase: ['error', {allow: ['^unstable__', '^internal_']}], 'unicorn/filename-case': 'off', 'react/default-props-match-prop-types': 'off', 'unicorn/prevent-abbreviations': 'off', 'react/require-default-props': 'off', 'react/jsx-curly-brace-presence': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/promise-function-async': 'warn', '@typescript-eslint/explicit-function-return': 'off', '@typescript-eslint/explicit-function-return-type': 'off', 'dot-notation': 'off', 'react/boolean-prop-naming': 'off', 'unicorn/prefer-dom-node-remove': 'off', 'unicorn/prefer-event-target': 'off', 'unicorn/consistent-existence-index-check': 'off', 'unicorn/prefer-string-raw': 'off', 'promise/prefer-await-to-then': 'off', }, }, { files: ['examples/**/*.{ts,tsx}', 'benchmark/**/*.{ts,tsx}'], rules: { 'import-x/no-unassigned-import': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/restrict-plus-operands': 'off', }, }, ]; export default xoConfig;