├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmrc ├── benchmark ├── simple │ ├── index.ts │ └── simple.tsx └── static │ ├── index.ts │ └── static.tsx ├── examples ├── borders │ ├── borders.tsx │ └── index.ts ├── counter │ ├── counter.tsx │ └── index.ts ├── jest │ ├── index.ts │ ├── jest.tsx │ ├── summary.tsx │ └── test.tsx ├── justify-content │ ├── index.ts │ └── justify-content.tsx ├── static │ ├── index.ts │ └── static.tsx ├── subprocess-output │ ├── index.ts │ └── subprocess-output.tsx ├── suspense │ ├── index.ts │ └── suspense.tsx ├── table │ ├── index.ts │ └── table.tsx ├── use-focus-with-id │ ├── index.ts │ └── use-focus-with-id.tsx ├── use-focus │ ├── index.ts │ └── use-focus.tsx ├── use-input │ ├── index.ts │ └── use-input.tsx ├── use-stderr │ ├── index.ts │ └── use-stderr.tsx └── use-stdout │ ├── index.ts │ └── use-stdout.tsx ├── license ├── media ├── box-borderColor.jpg ├── box-borderStyle.jpg ├── demo.js ├── demo.svg ├── devtools.jpg ├── logo.png ├── text-backgroundColor.jpg ├── text-color.jpg ├── text-dimColor.jpg └── text-inverse.jpg ├── package.json ├── readme.md ├── src ├── colorize.ts ├── components │ ├── App.tsx │ ├── AppContext.ts │ ├── Box.tsx │ ├── ErrorOverview.tsx │ ├── FocusContext.ts │ ├── Newline.tsx │ ├── Spacer.tsx │ ├── Static.tsx │ ├── StderrContext.ts │ ├── StdinContext.ts │ ├── StdoutContext.ts │ ├── Text.tsx │ └── Transform.tsx ├── devtools-window-polyfill.ts ├── devtools.ts ├── dom.ts ├── get-max-width.ts ├── global.d.ts ├── hooks │ ├── use-app.ts │ ├── use-focus-manager.ts │ ├── use-focus.ts │ ├── use-input.ts │ ├── use-stderr.ts │ ├── use-stdin.ts │ └── use-stdout.ts ├── index.ts ├── ink.tsx ├── instances.ts ├── log-update.ts ├── measure-element.ts ├── measure-text.ts ├── output.ts ├── parse-keypress.ts ├── reconciler.ts ├── render-border.ts ├── render-node-to-output.ts ├── render.ts ├── renderer.ts ├── squash-text-nodes.ts ├── styles.ts └── wrap-text.ts ├── test ├── borders.tsx ├── components.tsx ├── display.tsx ├── errors.tsx ├── exit.tsx ├── fixtures │ ├── 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.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-thrown-error.tsx │ ├── use-input-ctrl-c.tsx │ ├── use-input-multiple.tsx │ ├── use-input.tsx │ └── use-stdout.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-stdout.ts │ ├── render-to-string.ts │ └── run.ts ├── hooks.tsx ├── margin.tsx ├── measure-element.tsx ├── overflow.tsx ├── padding.tsx ├── reconciler.tsx ├── render.tsx ├── text.tsx ├── tsconfig.json └── width-height.tsx └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Node.js ${{ matrix.node_version }} 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node_version: 10 | - 20 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Use Node.js ${{ matrix.node_version }} 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: ${{ matrix.node_version }} 17 | - run: npm install 18 | - run: npm test -- --serial 19 | env: 20 | FORCE_COLOR: true 21 | CI: false 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /benchmark/simple/index.ts: -------------------------------------------------------------------------------- 1 | import './simple.js'; 2 | -------------------------------------------------------------------------------- /benchmark/simple/simple.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Box, Text} from '../../src/index.js'; 3 | 4 | function App() { 5 | return ( 6 | 7 | 8 | {/* eslint-disable-next-line react/jsx-curly-brace-presence */} 9 | {'Hello World'} 10 | 11 | 12 | 13 | 14 | Cupcake ipsum dolor sit amet candy candy. Sesame snaps cookie I love 15 | tootsie roll apple pie bonbon wafer. Caramels sesame snaps icing 16 | cotton candy I love cookie sweet roll. I love bonbon sweet. 17 | 18 | 19 | 20 | 21 | 22 | Colors: 23 | 24 | 25 | 26 | 27 | - Red 28 | 29 | 30 | - Blue 31 | 32 | 33 | - Green 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | const {rerender} = render(); 42 | 43 | for (let index = 0; index < 100_000; index++) { 44 | rerender(); 45 | } 46 | -------------------------------------------------------------------------------- /benchmark/static/index.ts: -------------------------------------------------------------------------------- 1 | import './static.js'; 2 | -------------------------------------------------------------------------------- /benchmark/static/static.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Box, Text, Static} from '../../src/index.js'; 3 | 4 | function App() { 5 | const [items, setItems] = React.useState< 6 | Array<{ 7 | id: number; 8 | }> 9 | >([]); 10 | const itemCountReference = React.useRef(0); 11 | 12 | React.useEffect(() => { 13 | let timer: NodeJS.Timeout | undefined; 14 | 15 | const run = () => { 16 | if (itemCountReference.current++ > 1000) { 17 | return; 18 | } 19 | 20 | setItems(previousItems => [ 21 | ...previousItems, 22 | { 23 | id: previousItems.length, 24 | }, 25 | ]); 26 | 27 | timer = setTimeout(run, 10); 28 | }; 29 | 30 | run(); 31 | 32 | return () => { 33 | clearTimeout(timer); 34 | }; 35 | }, []); 36 | 37 | return ( 38 | 39 | 40 | {(item, index) => ( 41 | 42 | Item #{index} 43 | Item content 44 | 45 | )} 46 | 47 | 48 | 49 | 50 | {/* eslint-disable-next-line react/jsx-curly-brace-presence */} 51 | {'Hello World'} 52 | 53 | 54 | Rendered: {items.length} 55 | 56 | 57 | 58 | Cupcake ipsum dolor sit amet candy candy. Sesame snaps cookie I love 59 | tootsie roll apple pie bonbon wafer. Caramels sesame snaps icing 60 | cotton candy I love cookie sweet roll. I love bonbon sweet. 61 | 62 | 63 | 64 | 65 | 66 | Colors: 67 | 68 | 69 | 70 | 71 | - Red 72 | 73 | 74 | - Blue 75 | 76 | 77 | - Green 78 | 79 | 80 | 81 | 82 | 83 | ); 84 | } 85 | 86 | render(); 87 | -------------------------------------------------------------------------------- /examples/borders/borders.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Box, Text} from '../../src/index.js'; 3 | 4 | function Borders() { 5 | return ( 6 | 7 | 8 | 9 | single 10 | 11 | 12 | 13 | double 14 | 15 | 16 | 17 | round 18 | 19 | 20 | 21 | bold 22 | 23 | 24 | 25 | 26 | 27 | singleDouble 28 | 29 | 30 | 31 | doubleSingle 32 | 33 | 34 | 35 | classic 36 | 37 | 38 | 39 | ); 40 | } 41 | 42 | render(); 43 | -------------------------------------------------------------------------------- /examples/borders/index.ts: -------------------------------------------------------------------------------- 1 | import './borders.js'; 2 | -------------------------------------------------------------------------------- /examples/counter/counter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text} from '../../src/index.js'; 3 | 4 | function Counter() { 5 | const [counter, setCounter] = React.useState(0); 6 | 7 | React.useEffect(() => { 8 | const timer = setInterval(() => { 9 | setCounter(prevCounter => prevCounter + 1); // eslint-disable-line unicorn/prevent-abbreviations 10 | }, 100); 11 | 12 | return () => { 13 | clearInterval(timer); 14 | }; 15 | }, []); 16 | 17 | return {counter} tests passed; 18 | } 19 | 20 | render(); 21 | -------------------------------------------------------------------------------- /examples/counter/index.ts: -------------------------------------------------------------------------------- 1 | import './counter.js'; 2 | -------------------------------------------------------------------------------- /examples/jest/index.ts: -------------------------------------------------------------------------------- 1 | import './jest.js'; 2 | -------------------------------------------------------------------------------- /examples/jest/jest.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PQueue from 'p-queue'; 3 | import delay from 'delay'; 4 | import ms from 'ms'; 5 | import {Static, Box, render} from '../../src/index.js'; 6 | import Summary from './summary.jsx'; 7 | import Test from './test.js'; 8 | 9 | const paths = [ 10 | 'tests/login.js', 11 | 'tests/signup.js', 12 | 'tests/forgot-password.js', 13 | 'tests/reset-password.js', 14 | 'tests/view-profile.js', 15 | 'tests/edit-profile.js', 16 | 'tests/delete-profile.js', 17 | 'tests/posts.js', 18 | 'tests/post.js', 19 | 'tests/comments.js', 20 | ]; 21 | 22 | type State = { 23 | startTime: number; 24 | completedTests: Array<{ 25 | path: string; 26 | status: string; 27 | }>; 28 | runningTests: Array<{ 29 | path: string; 30 | status: string; 31 | }>; 32 | }; 33 | 34 | class Jest extends React.Component, State> { 35 | constructor(properties: Record) { 36 | super(properties); 37 | 38 | this.state = { 39 | startTime: Date.now(), 40 | completedTests: [], 41 | runningTests: [], 42 | }; 43 | } 44 | 45 | render() { 46 | const {startTime, completedTests, runningTests} = this.state; 47 | 48 | return ( 49 | 50 | 51 | {test => ( 52 | 53 | )} 54 | 55 | 56 | {runningTests.length > 0 && ( 57 | 58 | {runningTests.map(test => ( 59 | 60 | ))} 61 | 62 | )} 63 | 64 | test.status === 'pass').length} 67 | failed={completedTests.filter(test => test.status === 'fail').length} 68 | time={ms(Date.now() - startTime)} 69 | /> 70 | 71 | ); 72 | } 73 | 74 | componentDidMount() { 75 | const queue = new PQueue({concurrency: 4}); 76 | 77 | for (const path of paths) { 78 | void queue.add(this.runTest.bind(this, path)); 79 | } 80 | } 81 | 82 | async runTest(path: string) { 83 | this.setState(previousState => ({ 84 | runningTests: [ 85 | ...previousState.runningTests, 86 | { 87 | status: 'runs', 88 | path, 89 | }, 90 | ], 91 | })); 92 | 93 | await delay(1000 * Math.random()); 94 | 95 | this.setState(previousState => ({ 96 | runningTests: previousState.runningTests.filter( 97 | test => test.path !== path, 98 | ), 99 | completedTests: [ 100 | ...previousState.completedTests, 101 | { 102 | status: Math.random() < 0.5 ? 'pass' : 'fail', 103 | path, 104 | }, 105 | ], 106 | })); 107 | } 108 | } 109 | 110 | render(); 111 | -------------------------------------------------------------------------------- /examples/jest/summary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Box, Text} from '../../src/index.js'; 3 | 4 | type Properties = { 5 | readonly isFinished: boolean; 6 | readonly passed: number; 7 | readonly failed: number; 8 | readonly time: string; 9 | }; 10 | 11 | function Summary({isFinished, passed, failed, time}: Properties) { 12 | return ( 13 | 14 | 15 | 16 | Test Suites: 17 | 18 | {failed > 0 && ( 19 | 20 | {failed} failed,{' '} 21 | 22 | )} 23 | {passed > 0 && ( 24 | 25 | {passed} passed,{' '} 26 | 27 | )} 28 | {passed + failed} total 29 | 30 | 31 | 32 | 33 | Time: 34 | 35 | 36 | {time} 37 | 38 | 39 | {isFinished && ( 40 | 41 | Ran all test suites. 42 | 43 | )} 44 | 45 | ); 46 | } 47 | 48 | export default Summary; 49 | -------------------------------------------------------------------------------- /examples/jest/test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Box, Text} from '../../src/index.js'; 3 | 4 | const getBackgroundForStatus = (status: string): string | undefined => { 5 | switch (status) { 6 | case 'runs': { 7 | return 'yellow'; 8 | } 9 | 10 | case 'pass': { 11 | return 'green'; 12 | } 13 | 14 | case 'fail': { 15 | return 'red'; 16 | } 17 | 18 | default: { 19 | return undefined; 20 | } 21 | } 22 | }; 23 | 24 | type Properties = { 25 | readonly status: string; 26 | readonly path: string; 27 | }; 28 | 29 | function Test({status, path}: Properties) { 30 | return ( 31 | 32 | 33 | {` ${status.toUpperCase()} `} 34 | 35 | 36 | 37 | {path.split('/')[0]}/ 38 | 39 | 40 | {path.split('/')[1]} 41 | 42 | 43 | 44 | ); 45 | } 46 | 47 | export default Test; 48 | -------------------------------------------------------------------------------- /examples/justify-content/index.ts: -------------------------------------------------------------------------------- 1 | import './justify-content.js'; 2 | -------------------------------------------------------------------------------- /examples/justify-content/justify-content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Box, Text} from '../../src/index.js'; 3 | 4 | function JustifyContent() { 5 | return ( 6 | 7 | 8 | [ 9 | 10 | X 11 | Y 12 | 13 | ] flex-start 14 | 15 | 16 | [ 17 | 18 | X 19 | Y 20 | 21 | ] flex-end 22 | 23 | 24 | [ 25 | 26 | X 27 | Y 28 | 29 | ] center 30 | 31 | 32 | [ 33 | 34 | X 35 | Y 36 | 37 | ] space-around 38 | 39 | 40 | [ 41 | 42 | X 43 | Y 44 | 45 | ] space-between 46 | 47 | 48 | [ 49 | 50 | X 51 | Y 52 | 53 | ] space-evenly 54 | 55 | 56 | ); 57 | } 58 | 59 | render(); 60 | -------------------------------------------------------------------------------- /examples/static/index.ts: -------------------------------------------------------------------------------- 1 | import './static.js'; 2 | -------------------------------------------------------------------------------- /examples/static/static.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Box, Text, render, Static} from '../../src/index.js'; 3 | 4 | function Example() { 5 | const [tests, setTests] = React.useState< 6 | Array<{ 7 | id: number; 8 | title: string; 9 | }> 10 | >([]); 11 | 12 | React.useEffect(() => { 13 | let completedTests = 0; 14 | let timer: NodeJS.Timeout | undefined; 15 | 16 | const run = () => { 17 | if (completedTests++ < 10) { 18 | setTests(previousTests => [ 19 | ...previousTests, 20 | { 21 | id: previousTests.length, 22 | title: `Test #${previousTests.length + 1}`, 23 | }, 24 | ]); 25 | 26 | timer = setTimeout(run, 100); 27 | } 28 | }; 29 | 30 | run(); 31 | 32 | return () => { 33 | clearTimeout(timer); 34 | }; 35 | }, []); 36 | 37 | return ( 38 | <> 39 | 40 | {test => ( 41 | 42 | ✔ {test.title} 43 | 44 | )} 45 | 46 | 47 | 48 | Completed tests: {tests.length} 49 | 50 | 51 | ); 52 | } 53 | 54 | render(); 55 | -------------------------------------------------------------------------------- /examples/subprocess-output/index.ts: -------------------------------------------------------------------------------- 1 | import './subprocess-output.js'; 2 | -------------------------------------------------------------------------------- /examples/subprocess-output/subprocess-output.tsx: -------------------------------------------------------------------------------- 1 | import childProcess from 'node:child_process'; 2 | import type Buffer from 'node:buffer'; 3 | import React from 'react'; 4 | import stripAnsi from 'strip-ansi'; 5 | import {render, Text, Box} from '../../src/index.js'; 6 | 7 | function SubprocessOutput() { 8 | const [output, setOutput] = React.useState(''); 9 | 10 | React.useEffect(() => { 11 | const subProcess = childProcess.spawn('npm', [ 12 | 'run', 13 | 'example', 14 | 'examples/jest', 15 | ]); 16 | 17 | // eslint-disable-next-line @typescript-eslint/ban-types 18 | subProcess.stdout.on('data', (newOutput: Buffer) => { 19 | const lines = stripAnsi(newOutput.toString('utf8')).split('\n'); 20 | setOutput(lines.slice(-5).join('\n')); 21 | }); 22 | }, [setOutput]); 23 | 24 | return ( 25 | 26 | Сommand output: 27 | 28 | {output} 29 | 30 | 31 | ); 32 | } 33 | 34 | render(); 35 | -------------------------------------------------------------------------------- /examples/suspense/index.ts: -------------------------------------------------------------------------------- 1 | import './suspense.js'; 2 | -------------------------------------------------------------------------------- /examples/suspense/suspense.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text} from '../../src/index.js'; 3 | 4 | let promise: Promise | undefined; 5 | let state: string | undefined; 6 | let value: string | undefined; 7 | 8 | const read = () => { 9 | if (!promise) { 10 | promise = new Promise(resolve => { 11 | setTimeout(resolve, 500); 12 | }); 13 | 14 | state = 'pending'; 15 | 16 | (async () => { 17 | await promise; 18 | state = 'done'; 19 | value = 'Hello World'; 20 | })(); 21 | } 22 | 23 | if (state === 'pending') { 24 | // eslint-disable-next-line @typescript-eslint/only-throw-error 25 | throw promise; 26 | } 27 | 28 | if (state === 'done') { 29 | return value; 30 | } 31 | }; 32 | 33 | function Example() { 34 | const message = read(); 35 | return {message}; 36 | } 37 | 38 | function Fallback() { 39 | return Loading...; 40 | } 41 | 42 | render( 43 | }> 44 | 45 | , 46 | ); 47 | -------------------------------------------------------------------------------- /examples/table/index.ts: -------------------------------------------------------------------------------- 1 | import './table.js'; 2 | -------------------------------------------------------------------------------- /examples/table/table.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {faker} from '@faker-js/faker'; 3 | import {Box, Text, render} from '../../src/index.js'; 4 | 5 | const users = Array.from({length: 10}) 6 | .fill(true) 7 | .map((_, index) => ({ 8 | id: index, 9 | name: faker.internet.username(), 10 | email: faker.internet.email(), 11 | })); 12 | 13 | function Table() { 14 | return ( 15 | 16 | 17 | 18 | ID 19 | 20 | 21 | 22 | Name 23 | 24 | 25 | 26 | Email 27 | 28 | 29 | 30 | {users.map(user => ( 31 | 32 | 33 | {user.id} 34 | 35 | 36 | 37 | {user.name} 38 | 39 | 40 | 41 | {user.email} 42 | 43 | 44 | ))} 45 | 46 | ); 47 | } 48 | 49 | render(); 50 | -------------------------------------------------------------------------------- /examples/use-focus-with-id/index.ts: -------------------------------------------------------------------------------- 1 | import './use-focus-with-id.js'; 2 | -------------------------------------------------------------------------------- /examples/use-focus-with-id/use-focus-with-id.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | render, 4 | Box, 5 | Text, 6 | useFocus, 7 | useInput, 8 | useFocusManager, 9 | } from '../../src/index.js'; 10 | 11 | function Focus() { 12 | const {focus} = useFocusManager(); 13 | 14 | useInput(input => { 15 | if (input === '1') { 16 | focus('1'); 17 | } 18 | 19 | if (input === '2') { 20 | focus('2'); 21 | } 22 | 23 | if (input === '3') { 24 | focus('3'); 25 | } 26 | }); 27 | 28 | return ( 29 | 30 | 31 | 32 | Press Tab to focus next element, Shift+Tab to focus previous element, 33 | Esc to reset focus. 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | type ItemProperties = { 44 | readonly id: number; 45 | readonly label: string; 46 | }; 47 | 48 | function Item({label, id}: ItemProperties) { 49 | const {isFocused} = useFocus({id}); 50 | 51 | return ( 52 | 53 | {label} {isFocused && (focused)} 54 | 55 | ); 56 | } 57 | 58 | render(); 59 | -------------------------------------------------------------------------------- /examples/use-focus/index.ts: -------------------------------------------------------------------------------- 1 | import './use-focus.js'; 2 | -------------------------------------------------------------------------------- /examples/use-focus/use-focus.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Box, Text, render, useFocus} from '../../src/index.js'; 3 | 4 | function Focus() { 5 | return ( 6 | 7 | 8 | 9 | Press Tab to focus next element, Shift+Tab to focus previous element, 10 | Esc to reset focus. 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | function Item({label}) { 21 | const {isFocused} = useFocus(); 22 | return ( 23 | 24 | {label} {isFocused && (focused)} 25 | 26 | ); 27 | } 28 | 29 | render(); 30 | -------------------------------------------------------------------------------- /examples/use-input/index.ts: -------------------------------------------------------------------------------- 1 | import './use-input.js'; 2 | -------------------------------------------------------------------------------- /examples/use-input/use-input.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, useInput, useApp, Box, Text} from '../../src/index.js'; 3 | 4 | function Robot() { 5 | const {exit} = useApp(); 6 | const [x, setX] = React.useState(1); 7 | const [y, setY] = React.useState(1); 8 | 9 | useInput((input, key) => { 10 | if (input === 'q') { 11 | exit(); 12 | } 13 | 14 | if (key.leftArrow) { 15 | setX(Math.max(1, x - 1)); 16 | } 17 | 18 | if (key.rightArrow) { 19 | setX(Math.min(20, x + 1)); 20 | } 21 | 22 | if (key.upArrow) { 23 | setY(Math.max(1, y - 1)); 24 | } 25 | 26 | if (key.downArrow) { 27 | setY(Math.min(10, y + 1)); 28 | } 29 | }); 30 | 31 | return ( 32 | 33 | Use arrow keys to move the face. Press “q” to exit. 34 | 35 | ^_^ 36 | 37 | 38 | ); 39 | } 40 | 41 | render(); 42 | -------------------------------------------------------------------------------- /examples/use-stderr/index.ts: -------------------------------------------------------------------------------- 1 | import './use-stderr.js'; 2 | -------------------------------------------------------------------------------- /examples/use-stderr/use-stderr.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text, useStderr} from '../../src/index.js'; 3 | 4 | function Example() { 5 | const {write} = useStderr(); 6 | 7 | React.useEffect(() => { 8 | const timer = setInterval(() => { 9 | write('Hello from Ink to stderr\n'); 10 | }, 1000); 11 | 12 | return () => { 13 | clearInterval(timer); 14 | }; 15 | }, []); 16 | 17 | return Hello World; 18 | } 19 | 20 | render(); 21 | -------------------------------------------------------------------------------- /examples/use-stdout/index.ts: -------------------------------------------------------------------------------- 1 | import './use-stdout.js'; 2 | -------------------------------------------------------------------------------- /examples/use-stdout/use-stdout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Box, Text, useStdout} from '../../src/index.js'; 3 | 4 | function Example() { 5 | const {stdout, write} = useStdout(); 6 | 7 | React.useEffect(() => { 8 | const timer = setInterval(() => { 9 | write('Hello from Ink to stdout\n'); 10 | }, 1000); 11 | 12 | return () => { 13 | clearInterval(timer); 14 | }; 15 | }, []); 16 | 17 | return ( 18 | 19 | 20 | Terminal dimensions: 21 | 22 | 23 | 24 | 25 | Width: {stdout.columns} 26 | 27 | 28 | 29 | 30 | Height: {stdout.rows} 31 | 32 | 33 | 34 | ); 35 | } 36 | 37 | render(); 38 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Vadym Demedes (github.com/vadimdemedes) 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /media/box-borderColor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/ink/7f2bc3cad6ce21d9f127a15cb58008e02ba48b11/media/box-borderColor.jpg -------------------------------------------------------------------------------- /media/box-borderStyle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/ink/7f2bc3cad6ce21d9f127a15cb58008e02ba48b11/media/box-borderStyle.jpg -------------------------------------------------------------------------------- /media/demo.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import React from 'react'; 3 | import {render, Box, Text} from 'ink'; 4 | 5 | class Counter extends React.PureComponent { 6 | constructor() { 7 | super(); 8 | 9 | this.state = { 10 | i: 0, 11 | }; 12 | } 13 | 14 | render() { 15 | return React.createElement( 16 | Box, 17 | {flexDirection: 'column'}, 18 | React.createElement( 19 | Box, 20 | {}, 21 | React.createElement(Text, {color: 'blue'}, '~/Projects/ink '), 22 | ), 23 | React.createElement( 24 | Box, 25 | {}, 26 | React.createElement(Text, {color: 'magenta'}, '❯ '), 27 | React.createElement(Text, {color: 'green'}, 'node '), 28 | React.createElement(Text, {}, 'media/example'), 29 | ), 30 | React.createElement( 31 | Text, 32 | {color: 'green'}, 33 | `${this.state.i} tests passed`, 34 | ), 35 | ); 36 | } 37 | 38 | componentDidMount() { 39 | this.timer = setInterval(() => { 40 | if (this.state.i === 50) { 41 | process.exit(0); // eslint-disable-line unicorn/no-process-exit 42 | } 43 | 44 | this.setState(previousState => ({ 45 | i: previousState.i + 1, 46 | })); 47 | }, 100); 48 | } 49 | 50 | componentWillUnmount() { 51 | clearInterval(this.timer); 52 | } 53 | } 54 | 55 | render(React.createElement(Counter)); 56 | -------------------------------------------------------------------------------- /media/devtools.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/ink/7f2bc3cad6ce21d9f127a15cb58008e02ba48b11/media/devtools.jpg -------------------------------------------------------------------------------- /media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/ink/7f2bc3cad6ce21d9f127a15cb58008e02ba48b11/media/logo.png -------------------------------------------------------------------------------- /media/text-backgroundColor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/ink/7f2bc3cad6ce21d9f127a15cb58008e02ba48b11/media/text-backgroundColor.jpg -------------------------------------------------------------------------------- /media/text-color.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/ink/7f2bc3cad6ce21d9f127a15cb58008e02ba48b11/media/text-color.jpg -------------------------------------------------------------------------------- /media/text-dimColor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/ink/7f2bc3cad6ce21d9f127a15cb58008e02ba48b11/media/text-dimColor.jpg -------------------------------------------------------------------------------- /media/text-inverse.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vadimdemedes/ink/7f2bc3cad6ce21d9f127a15cb58008e02ba48b11/media/text-inverse.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ink", 3 | "version": "6.0.0", 4 | "description": "React for CLI", 5 | "license": "MIT", 6 | "repository": "vadimdemedes/ink", 7 | "author": { 8 | "name": "Vadim Demedes", 9 | "email": "vadimdemedes@hey.com", 10 | "url": "https://github.com/vadimdemedes" 11 | }, 12 | "type": "module", 13 | "exports": { 14 | "types": "./build/index.d.ts", 15 | "default": "./build/index.js" 16 | }, 17 | "engines": { 18 | "node": ">=20" 19 | }, 20 | "scripts": { 21 | "dev": "tsc --watch", 22 | "build": "tsc", 23 | "prepare": "npm run build", 24 | "test": "tsc --noEmit && xo && FORCE_COLOR=true ava", 25 | "example": "NODE_NO_WARNINGS=1 node --loader ts-node/esm", 26 | "benchmark": "NODE_NO_WARNINGS=1 node --loader ts-node/esm" 27 | }, 28 | "files": [ 29 | "build" 30 | ], 31 | "keywords": [ 32 | "react", 33 | "cli", 34 | "jsx", 35 | "stdout", 36 | "components", 37 | "command-line", 38 | "preact", 39 | "redux", 40 | "print", 41 | "render", 42 | "colors", 43 | "text" 44 | ], 45 | "dependencies": { 46 | "@alcalzone/ansi-tokenize": "^0.1.3", 47 | "ansi-escapes": "^7.0.0", 48 | "ansi-styles": "^6.2.1", 49 | "auto-bind": "^5.0.1", 50 | "chalk": "^5.3.0", 51 | "cli-boxes": "^3.0.0", 52 | "cli-cursor": "^4.0.0", 53 | "cli-truncate": "^4.0.0", 54 | "code-excerpt": "^4.0.0", 55 | "es-toolkit": "^1.22.0", 56 | "indent-string": "^5.0.0", 57 | "is-in-ci": "^1.0.0", 58 | "patch-console": "^2.0.0", 59 | "react-reconciler": "^0.32.0", 60 | "scheduler": "^0.23.0", 61 | "signal-exit": "^3.0.7", 62 | "slice-ansi": "^7.1.0", 63 | "stack-utils": "^2.0.6", 64 | "string-width": "^7.2.0", 65 | "type-fest": "^4.27.0", 66 | "widest-line": "^5.0.0", 67 | "wrap-ansi": "^9.0.0", 68 | "ws": "^8.18.0", 69 | "yoga-layout": "~3.2.1" 70 | }, 71 | "devDependencies": { 72 | "@faker-js/faker": "^9.8.0", 73 | "@sindresorhus/tsconfig": "^7.0.0", 74 | "@types/benchmark": "^2.1.2", 75 | "@types/ms": "^2.1.0", 76 | "@types/node": "^22.15.24", 77 | "@types/react": "^19.1.5", 78 | "@types/react-reconciler": "^0.32.0", 79 | "@types/scheduler": "^0.23.0", 80 | "@types/signal-exit": "^3.0.0", 81 | "@types/sinon": "^17.0.3", 82 | "@types/stack-utils": "^2.0.2", 83 | "@types/ws": "^8.18.1", 84 | "@vdemedes/prettier-config": "^2.0.1", 85 | "ava": "^5.1.1", 86 | "boxen": "^8.0.1", 87 | "delay": "^6.0.0", 88 | "eslint-config-xo-react": "0.27.0", 89 | "eslint-plugin-react": "^7.37.5", 90 | "eslint-plugin-react-hooks": "^5.0.0", 91 | "ms": "^2.1.3", 92 | "node-pty": "^1.0.0", 93 | "p-queue": "^8.0.0", 94 | "prettier": "^3.3.3", 95 | "react": "^19.1.0", 96 | "react-devtools-core": "^6.1.2", 97 | "sinon": "^20.0.0", 98 | "strip-ansi": "^7.1.0", 99 | "ts-node": "^10.9.2", 100 | "typescript": "^5.8.3", 101 | "xo": "^0.59.3" 102 | }, 103 | "peerDependencies": { 104 | "@types/react": ">=19.0.0", 105 | "react": ">=19.0.0", 106 | "react-devtools-core": "^4.19.1" 107 | }, 108 | "peerDependenciesMeta": { 109 | "@types/react": { 110 | "optional": true 111 | }, 112 | "react-devtools-core": { 113 | "optional": true 114 | } 115 | }, 116 | "ava": { 117 | "workerThreads": false, 118 | "files": [ 119 | "test/**/*", 120 | "!test/helpers/**/*", 121 | "!test/fixtures/**/*" 122 | ], 123 | "extensions": { 124 | "ts": "module", 125 | "tsx": "module" 126 | }, 127 | "nodeArguments": [ 128 | "--loader=ts-node/esm" 129 | ] 130 | }, 131 | "xo": { 132 | "extends": [ 133 | "xo-react" 134 | ], 135 | "plugins": [ 136 | "react" 137 | ], 138 | "prettier": true, 139 | "rules": { 140 | "react/no-unescaped-entities": "off", 141 | "react/state-in-constructor": "off", 142 | "react/jsx-indent": "off", 143 | "react/prop-types": "off", 144 | "unicorn/import-index": "off", 145 | "import/no-useless-path-segments": "off", 146 | "react-hooks/exhaustive-deps": "off", 147 | "complexity": "off" 148 | }, 149 | "ignores": [ 150 | "src/parse-keypress.ts" 151 | ], 152 | "overrides": [ 153 | { 154 | "files": [ 155 | "src/**/*.{ts,tsx}", 156 | "test/**/*.{ts,tsx}" 157 | ], 158 | "rules": { 159 | "no-unused-expressions": "off", 160 | "camelcase": [ 161 | "error", 162 | { 163 | "allow": [ 164 | "^unstable__", 165 | "^internal_" 166 | ] 167 | } 168 | ], 169 | "unicorn/filename-case": "off", 170 | "react/default-props-match-prop-types": "off", 171 | "unicorn/prevent-abbreviations": "off", 172 | "react/require-default-props": "off", 173 | "react/jsx-curly-brace-presence": "off", 174 | "@typescript-eslint/no-empty-function": "off", 175 | "@typescript-eslint/promise-function-async": "warn", 176 | "@typescript-eslint/explicit-function-return": "off", 177 | "@typescript-eslint/explicit-function-return-type": "off", 178 | "dot-notation": "off", 179 | "react/boolean-prop-naming": "off", 180 | "unicorn/prefer-dom-node-remove": "off", 181 | "unicorn/prefer-event-target": "off" 182 | } 183 | }, 184 | { 185 | "files": [ 186 | "examples/**/*.{ts,tsx}", 187 | "benchmark/**/*.{ts,tsx}" 188 | ], 189 | "rules": { 190 | "import/no-unassigned-import": "off" 191 | } 192 | } 193 | ] 194 | }, 195 | "prettier": "@vdemedes/prettier-config" 196 | } 197 | -------------------------------------------------------------------------------- /src/colorize.ts: -------------------------------------------------------------------------------- 1 | import chalk, {type ForegroundColorName, type BackgroundColorName} from 'chalk'; 2 | 3 | type ColorType = 'foreground' | 'background'; 4 | 5 | const rgbRegex = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/; 6 | const ansiRegex = /^ansi256\(\s?(\d+)\s?\)$/; 7 | 8 | const isNamedColor = (color: string): color is ForegroundColorName => { 9 | return color in chalk; 10 | }; 11 | 12 | const colorize = ( 13 | str: string, 14 | color: string | undefined, 15 | type: ColorType, 16 | ): string => { 17 | if (!color) { 18 | return str; 19 | } 20 | 21 | if (isNamedColor(color)) { 22 | if (type === 'foreground') { 23 | return chalk[color](str); 24 | } 25 | 26 | const methodName = `bg${ 27 | color[0]!.toUpperCase() + color.slice(1) 28 | }` as BackgroundColorName; 29 | 30 | return chalk[methodName](str); 31 | } 32 | 33 | if (color.startsWith('#')) { 34 | return type === 'foreground' 35 | ? chalk.hex(color)(str) 36 | : chalk.bgHex(color)(str); 37 | } 38 | 39 | if (color.startsWith('ansi256')) { 40 | const matches = ansiRegex.exec(color); 41 | 42 | if (!matches) { 43 | return str; 44 | } 45 | 46 | const value = Number(matches[1]); 47 | 48 | return type === 'foreground' 49 | ? chalk.ansi256(value)(str) 50 | : chalk.bgAnsi256(value)(str); 51 | } 52 | 53 | if (color.startsWith('rgb')) { 54 | const matches = rgbRegex.exec(color); 55 | 56 | if (!matches) { 57 | return str; 58 | } 59 | 60 | const firstValue = Number(matches[1]); 61 | const secondValue = Number(matches[2]); 62 | const thirdValue = Number(matches[3]); 63 | 64 | return type === 'foreground' 65 | ? chalk.rgb(firstValue, secondValue, thirdValue)(str) 66 | : chalk.bgRgb(firstValue, secondValue, thirdValue)(str); 67 | } 68 | 69 | return str; 70 | }; 71 | 72 | export default colorize; 73 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'node:events'; 2 | import process from 'node:process'; 3 | import React, {PureComponent, type ReactNode} from 'react'; 4 | import cliCursor from 'cli-cursor'; 5 | import AppContext from './AppContext.js'; 6 | import StdinContext from './StdinContext.js'; 7 | import StdoutContext from './StdoutContext.js'; 8 | import StderrContext from './StderrContext.js'; 9 | import FocusContext from './FocusContext.js'; 10 | import ErrorOverview from './ErrorOverview.js'; 11 | 12 | const tab = '\t'; 13 | const shiftTab = '\u001B[Z'; 14 | const escape = '\u001B'; 15 | 16 | type Props = { 17 | readonly children: ReactNode; 18 | readonly stdin: NodeJS.ReadStream; 19 | readonly stdout: NodeJS.WriteStream; 20 | readonly stderr: NodeJS.WriteStream; 21 | readonly writeToStdout: (data: string) => void; 22 | readonly writeToStderr: (data: string) => void; 23 | readonly exitOnCtrlC: boolean; 24 | readonly onExit: (error?: Error) => void; 25 | }; 26 | 27 | type State = { 28 | readonly isFocusEnabled: boolean; 29 | readonly activeFocusId?: string; 30 | readonly focusables: Focusable[]; 31 | readonly error?: Error; 32 | }; 33 | 34 | type Focusable = { 35 | readonly id: string; 36 | readonly isActive: boolean; 37 | }; 38 | 39 | // Root component for all Ink apps 40 | // It renders stdin and stdout contexts, so that children can access them if needed 41 | // It also handles Ctrl+C exiting and cursor visibility 42 | export default class App extends PureComponent { 43 | static displayName = 'InternalApp'; 44 | 45 | static getDerivedStateFromError(error: Error) { 46 | return {error}; 47 | } 48 | 49 | override state = { 50 | isFocusEnabled: true, 51 | activeFocusId: undefined, 52 | focusables: [], 53 | error: undefined, 54 | }; 55 | 56 | // Count how many components enabled raw mode to avoid disabling 57 | // raw mode until all components don't need it anymore 58 | rawModeEnabledCount = 0; 59 | // eslint-disable-next-line @typescript-eslint/naming-convention 60 | internal_eventEmitter = new EventEmitter(); 61 | 62 | // Determines if TTY is supported on the provided stdin 63 | isRawModeSupported(): boolean { 64 | return this.props.stdin.isTTY; 65 | } 66 | 67 | override render() { 68 | return ( 69 | 75 | 87 | 94 | 101 | 116 | {this.state.error ? ( 117 | 118 | ) : ( 119 | this.props.children 120 | )} 121 | 122 | 123 | 124 | 125 | 126 | ); 127 | } 128 | 129 | override componentDidMount() { 130 | cliCursor.hide(this.props.stdout); 131 | } 132 | 133 | override componentWillUnmount() { 134 | cliCursor.show(this.props.stdout); 135 | 136 | // ignore calling setRawMode on an handle stdin it cannot be called 137 | if (this.isRawModeSupported()) { 138 | this.handleSetRawMode(false); 139 | } 140 | } 141 | 142 | override componentDidCatch(error: Error) { 143 | this.handleExit(error); 144 | } 145 | 146 | handleSetRawMode = (isEnabled: boolean): void => { 147 | const {stdin} = this.props; 148 | 149 | if (!this.isRawModeSupported()) { 150 | if (stdin === process.stdin) { 151 | throw new Error( 152 | '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', 153 | ); 154 | } else { 155 | throw new Error( 156 | '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', 157 | ); 158 | } 159 | } 160 | 161 | stdin.setEncoding('utf8'); 162 | 163 | if (isEnabled) { 164 | // Ensure raw mode is enabled only once 165 | if (this.rawModeEnabledCount === 0) { 166 | stdin.ref(); 167 | stdin.setRawMode(true); 168 | stdin.addListener('readable', this.handleReadable); 169 | } 170 | 171 | this.rawModeEnabledCount++; 172 | return; 173 | } 174 | 175 | // Disable raw mode only when no components left that are using it 176 | if (--this.rawModeEnabledCount === 0) { 177 | stdin.setRawMode(false); 178 | stdin.removeListener('readable', this.handleReadable); 179 | stdin.unref(); 180 | } 181 | }; 182 | 183 | handleReadable = (): void => { 184 | let chunk; 185 | // eslint-disable-next-line @typescript-eslint/ban-types 186 | while ((chunk = this.props.stdin.read() as string | null) !== null) { 187 | this.handleInput(chunk); 188 | this.internal_eventEmitter.emit('input', chunk); 189 | } 190 | }; 191 | 192 | handleInput = (input: string): void => { 193 | // Exit on Ctrl+C 194 | // eslint-disable-next-line unicorn/no-hex-escape 195 | if (input === '\x03' && this.props.exitOnCtrlC) { 196 | this.handleExit(); 197 | } 198 | 199 | // Reset focus when there's an active focused component on Esc 200 | if (input === escape && this.state.activeFocusId) { 201 | this.setState({ 202 | activeFocusId: undefined, 203 | }); 204 | } 205 | 206 | if (this.state.isFocusEnabled && this.state.focusables.length > 0) { 207 | if (input === tab) { 208 | this.focusNext(); 209 | } 210 | 211 | if (input === shiftTab) { 212 | this.focusPrevious(); 213 | } 214 | } 215 | }; 216 | 217 | handleExit = (error?: Error): void => { 218 | if (this.isRawModeSupported()) { 219 | this.handleSetRawMode(false); 220 | } 221 | 222 | this.props.onExit(error); 223 | }; 224 | 225 | enableFocus = (): void => { 226 | this.setState({ 227 | isFocusEnabled: true, 228 | }); 229 | }; 230 | 231 | disableFocus = (): void => { 232 | this.setState({ 233 | isFocusEnabled: false, 234 | }); 235 | }; 236 | 237 | focus = (id: string): void => { 238 | this.setState(previousState => { 239 | const hasFocusableId = previousState.focusables.some( 240 | focusable => focusable?.id === id, 241 | ); 242 | 243 | if (!hasFocusableId) { 244 | return previousState; 245 | } 246 | 247 | return {activeFocusId: id}; 248 | }); 249 | }; 250 | 251 | focusNext = (): void => { 252 | this.setState(previousState => { 253 | const firstFocusableId = previousState.focusables.find( 254 | focusable => focusable.isActive, 255 | )?.id; 256 | const nextFocusableId = this.findNextFocusable(previousState); 257 | 258 | return { 259 | activeFocusId: nextFocusableId ?? firstFocusableId, 260 | }; 261 | }); 262 | }; 263 | 264 | focusPrevious = (): void => { 265 | this.setState(previousState => { 266 | const lastFocusableId = previousState.focusables.findLast( 267 | focusable => focusable.isActive, 268 | )?.id; 269 | const previousFocusableId = this.findPreviousFocusable(previousState); 270 | 271 | return { 272 | activeFocusId: previousFocusableId ?? lastFocusableId, 273 | }; 274 | }); 275 | }; 276 | 277 | addFocusable = (id: string, {autoFocus}: {autoFocus: boolean}): void => { 278 | this.setState(previousState => { 279 | let nextFocusId = previousState.activeFocusId; 280 | 281 | if (!nextFocusId && autoFocus) { 282 | nextFocusId = id; 283 | } 284 | 285 | return { 286 | activeFocusId: nextFocusId, 287 | focusables: [ 288 | ...previousState.focusables, 289 | { 290 | id, 291 | isActive: true, 292 | }, 293 | ], 294 | }; 295 | }); 296 | }; 297 | 298 | removeFocusable = (id: string): void => { 299 | this.setState(previousState => ({ 300 | activeFocusId: 301 | previousState.activeFocusId === id 302 | ? undefined 303 | : previousState.activeFocusId, 304 | focusables: previousState.focusables.filter(focusable => { 305 | return focusable.id !== id; 306 | }), 307 | })); 308 | }; 309 | 310 | activateFocusable = (id: string): void => { 311 | this.setState(previousState => ({ 312 | focusables: previousState.focusables.map(focusable => { 313 | if (focusable.id !== id) { 314 | return focusable; 315 | } 316 | 317 | return { 318 | id, 319 | isActive: true, 320 | }; 321 | }), 322 | })); 323 | }; 324 | 325 | deactivateFocusable = (id: string): void => { 326 | this.setState(previousState => ({ 327 | activeFocusId: 328 | previousState.activeFocusId === id 329 | ? undefined 330 | : previousState.activeFocusId, 331 | focusables: previousState.focusables.map(focusable => { 332 | if (focusable.id !== id) { 333 | return focusable; 334 | } 335 | 336 | return { 337 | id, 338 | isActive: false, 339 | }; 340 | }), 341 | })); 342 | }; 343 | 344 | findNextFocusable = (state: State): string | undefined => { 345 | const activeIndex = state.focusables.findIndex(focusable => { 346 | return focusable.id === state.activeFocusId; 347 | }); 348 | 349 | for ( 350 | let index = activeIndex + 1; 351 | index < state.focusables.length; 352 | index++ 353 | ) { 354 | const focusable = state.focusables[index]; 355 | 356 | if (focusable?.isActive) { 357 | return focusable.id; 358 | } 359 | } 360 | 361 | return undefined; 362 | }; 363 | 364 | findPreviousFocusable = (state: State): string | undefined => { 365 | const activeIndex = state.focusables.findIndex(focusable => { 366 | return focusable.id === state.activeFocusId; 367 | }); 368 | 369 | for (let index = activeIndex - 1; index >= 0; index--) { 370 | const focusable = state.focusables[index]; 371 | 372 | if (focusable?.isActive) { 373 | return focusable.id; 374 | } 375 | } 376 | 377 | return undefined; 378 | }; 379 | } 380 | -------------------------------------------------------------------------------- /src/components/AppContext.ts: -------------------------------------------------------------------------------- 1 | import {createContext} from 'react'; 2 | 3 | export type Props = { 4 | /** 5 | * Exit (unmount) the whole Ink app. 6 | */ 7 | readonly exit: (error?: Error) => void; 8 | }; 9 | 10 | /** 11 | * `AppContext` is a React context, which exposes a method to manually exit the app (unmount). 12 | */ 13 | // eslint-disable-next-line @typescript-eslint/naming-convention 14 | const AppContext = createContext({ 15 | exit() {}, 16 | }); 17 | 18 | AppContext.displayName = 'InternalAppContext'; 19 | 20 | export default AppContext; 21 | -------------------------------------------------------------------------------- /src/components/Box.tsx: -------------------------------------------------------------------------------- 1 | import React, {forwardRef, type PropsWithChildren} from 'react'; 2 | import {type Except} from 'type-fest'; 3 | import {type Styles} from '../styles.js'; 4 | import {type DOMElement} from '../dom.js'; 5 | 6 | export type Props = Except; 7 | 8 | /** 9 | * `` is an essential Ink component to build your layout. It's like `
` in the browser. 10 | */ 11 | const Box = forwardRef>( 12 | ({children, ...style}, ref) => { 13 | return ( 14 | 26 | {children} 27 | 28 | ); 29 | }, 30 | ); 31 | 32 | Box.displayName = 'Box'; 33 | 34 | export default Box; 35 | -------------------------------------------------------------------------------- /src/components/ErrorOverview.tsx: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import {cwd} from 'node:process'; 3 | import React from 'react'; 4 | import StackUtils from 'stack-utils'; 5 | import codeExcerpt, {type CodeExcerpt} from 'code-excerpt'; 6 | import Box from './Box.js'; 7 | import Text from './Text.js'; 8 | 9 | // Error's source file is reported as file:///home/user/file.js 10 | // This function removes the file://[cwd] part 11 | const cleanupPath = (path: string | undefined): string | undefined => { 12 | return path?.replace(`file://${cwd()}/`, ''); 13 | }; 14 | 15 | const stackUtils = new StackUtils({ 16 | cwd: cwd(), 17 | internals: StackUtils.nodeInternals(), 18 | }); 19 | 20 | type Props = { 21 | readonly error: Error; 22 | }; 23 | 24 | export default function ErrorOverview({error}: Props) { 25 | const stack = error.stack ? error.stack.split('\n').slice(1) : undefined; 26 | const origin = stack ? stackUtils.parseLine(stack[0]!) : undefined; 27 | const filePath = cleanupPath(origin?.file); 28 | let excerpt: CodeExcerpt[] | undefined; 29 | let lineWidth = 0; 30 | 31 | if (filePath && origin?.line && fs.existsSync(filePath)) { 32 | const sourceCode = fs.readFileSync(filePath, 'utf8'); 33 | excerpt = codeExcerpt(sourceCode, origin.line); 34 | 35 | if (excerpt) { 36 | for (const {line} of excerpt) { 37 | lineWidth = Math.max(lineWidth, String(line).length); 38 | } 39 | } 40 | } 41 | 42 | return ( 43 | 44 | 45 | 46 | {' '} 47 | ERROR{' '} 48 | 49 | 50 | {error.message} 51 | 52 | 53 | {origin && filePath && ( 54 | 55 | 56 | {filePath}:{origin.line}:{origin.column} 57 | 58 | 59 | )} 60 | 61 | {origin && excerpt && ( 62 | 63 | {excerpt.map(({line, value}) => ( 64 | 65 | 66 | 71 | {String(line).padStart(lineWidth, ' ')}: 72 | 73 | 74 | 75 | 80 | {' ' + value} 81 | 82 | 83 | ))} 84 | 85 | )} 86 | 87 | {error.stack && ( 88 | 89 | {error.stack 90 | .split('\n') 91 | .slice(1) 92 | .map(line => { 93 | const parsedLine = stackUtils.parseLine(line); 94 | 95 | // If the line from the stack cannot be parsed, we print out the unparsed line. 96 | if (!parsedLine) { 97 | return ( 98 | 99 | - 100 | 101 | {line} 102 | 103 | 104 | ); 105 | } 106 | 107 | return ( 108 | 109 | - 110 | 111 | {parsedLine.function} 112 | 113 | 114 | {' '} 115 | ({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}: 116 | {parsedLine.column}) 117 | 118 | 119 | ); 120 | })} 121 | 122 | )} 123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /src/components/FocusContext.ts: -------------------------------------------------------------------------------- 1 | import {createContext} from 'react'; 2 | 3 | export type Props = { 4 | readonly activeId?: string; 5 | readonly add: (id: string, options: {autoFocus: boolean}) => void; 6 | readonly remove: (id: string) => void; 7 | readonly activate: (id: string) => void; 8 | readonly deactivate: (id: string) => void; 9 | readonly enableFocus: () => void; 10 | readonly disableFocus: () => void; 11 | readonly focusNext: () => void; 12 | readonly focusPrevious: () => void; 13 | readonly focus: (id: string) => void; 14 | }; 15 | 16 | // eslint-disable-next-line @typescript-eslint/naming-convention 17 | const FocusContext = createContext({ 18 | activeId: undefined, 19 | add() {}, 20 | remove() {}, 21 | activate() {}, 22 | deactivate() {}, 23 | enableFocus() {}, 24 | disableFocus() {}, 25 | focusNext() {}, 26 | focusPrevious() {}, 27 | focus() {}, 28 | }); 29 | 30 | FocusContext.displayName = 'InternalFocusContext'; 31 | 32 | export default FocusContext; 33 | -------------------------------------------------------------------------------- /src/components/Newline.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type Props = { 4 | /** 5 | * Number of newlines to insert. 6 | * 7 | * @default 1 8 | */ 9 | readonly count?: number; 10 | }; 11 | 12 | /** 13 | * Adds one or more newline (\n) characters. Must be used within components. 14 | */ 15 | export default function Newline({count = 1}: Props) { 16 | return {'\n'.repeat(count)}; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Spacer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from './Box.js'; 3 | 4 | /** 5 | * A flexible space that expands along the major axis of its containing layout. 6 | * It's useful as a shortcut for filling all the available spaces between elements. 7 | */ 8 | export default function Spacer() { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Static.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo, useState, useLayoutEffect, type ReactNode} from 'react'; 2 | import {type Styles} from '../styles.js'; 3 | 4 | export type Props = { 5 | /** 6 | * Array of items of any type to render using a function you pass as a component child. 7 | */ 8 | readonly items: T[]; 9 | 10 | /** 11 | * Styles to apply to a container of child elements. See for supported properties. 12 | */ 13 | readonly style?: Styles; 14 | 15 | /** 16 | * Function that is called to render every item in `items` array. 17 | * First argument is an item itself and second argument is index of that item in `items` array. 18 | * Note that `key` must be assigned to the root component. 19 | */ 20 | readonly children: (item: T, index: number) => ReactNode; 21 | }; 22 | 23 | /** 24 | * `` component permanently renders its output above everything else. 25 | * It's useful for displaying activity like completed tasks or logs - things that 26 | * are not changing after they're rendered (hence the name "Static"). 27 | * 28 | * It's preferred to use `` for use cases like these, when you can't know 29 | * or control the amount of items that need to be rendered. 30 | * 31 | * For example, [Tap](https://github.com/tapjs/node-tap) uses `` to display 32 | * a list of completed tests. [Gatsby](https://github.com/gatsbyjs/gatsby) uses it 33 | * to display a list of generated pages, while still displaying a live progress bar. 34 | */ 35 | export default function Static(props: Props) { 36 | const {items, children: render, style: customStyle} = props; 37 | const [index, setIndex] = useState(0); 38 | 39 | const itemsToRender: T[] = useMemo(() => { 40 | return items.slice(index); 41 | }, [items, index]); 42 | 43 | useLayoutEffect(() => { 44 | setIndex(items.length); 45 | }, [items.length]); 46 | 47 | const children = itemsToRender.map((item, itemIndex) => { 48 | return render(item, index + itemIndex); 49 | }); 50 | 51 | const style: Styles = useMemo( 52 | () => ({ 53 | position: 'absolute', 54 | flexDirection: 'column', 55 | ...customStyle, 56 | }), 57 | [customStyle], 58 | ); 59 | 60 | return ( 61 | 62 | {children} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/components/StderrContext.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {createContext} from 'react'; 3 | 4 | export type Props = { 5 | /** 6 | * Stderr stream passed to `render()` in `options.stderr` or `process.stderr` by default. 7 | */ 8 | readonly stderr: NodeJS.WriteStream; 9 | 10 | /** 11 | * Write any string to stderr, while preserving Ink's output. 12 | * It's useful when you want to display some external information outside of Ink's rendering and ensure there's no conflict between the two. 13 | * It's similar to ``, except it can't accept components, it only works with strings. 14 | */ 15 | readonly write: (data: string) => void; 16 | }; 17 | 18 | /** 19 | * `StderrContext` is a React context, which exposes stderr stream. 20 | */ 21 | // eslint-disable-next-line @typescript-eslint/naming-convention 22 | const StderrContext = createContext({ 23 | stderr: process.stderr, 24 | write() {}, 25 | }); 26 | 27 | StderrContext.displayName = 'InternalStderrContext'; 28 | 29 | export default StderrContext; 30 | -------------------------------------------------------------------------------- /src/components/StdinContext.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from 'node:events'; 2 | import process from 'node:process'; 3 | import {createContext} from 'react'; 4 | 5 | export type Props = { 6 | /** 7 | * Stdin stream passed to `render()` in `options.stdin` or `process.stdin` by default. Useful if your app needs to handle user input. 8 | */ 9 | readonly stdin: NodeJS.ReadStream; 10 | 11 | /** 12 | * 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`. 13 | * If the `stdin` stream passed to Ink does not support setRawMode, this function does nothing. 14 | */ 15 | readonly setRawMode: (value: boolean) => void; 16 | 17 | /** 18 | * 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. 19 | */ 20 | readonly isRawModeSupported: boolean; 21 | 22 | readonly internal_exitOnCtrlC: boolean; 23 | 24 | readonly internal_eventEmitter: EventEmitter; 25 | }; 26 | 27 | /** 28 | * `StdinContext` is a React context, which exposes input stream. 29 | */ 30 | // eslint-disable-next-line @typescript-eslint/naming-convention 31 | const StdinContext = createContext({ 32 | stdin: process.stdin, 33 | // eslint-disable-next-line @typescript-eslint/naming-convention 34 | internal_eventEmitter: new EventEmitter(), 35 | setRawMode() {}, 36 | isRawModeSupported: false, 37 | // eslint-disable-next-line @typescript-eslint/naming-convention 38 | internal_exitOnCtrlC: true, 39 | }); 40 | 41 | StdinContext.displayName = 'InternalStdinContext'; 42 | 43 | export default StdinContext; 44 | -------------------------------------------------------------------------------- /src/components/StdoutContext.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {createContext} from 'react'; 3 | 4 | export type Props = { 5 | /** 6 | * Stdout stream passed to `render()` in `options.stdout` or `process.stdout` by default. 7 | */ 8 | readonly stdout: NodeJS.WriteStream; 9 | 10 | /** 11 | * Write any string to stdout, while preserving Ink's output. 12 | * It's useful when you want to display some external information outside of Ink's rendering and ensure there's no conflict between the two. 13 | * It's similar to ``, except it can't accept components, it only works with strings. 14 | */ 15 | readonly write: (data: string) => void; 16 | }; 17 | 18 | /** 19 | * `StdoutContext` is a React context, which exposes stdout stream, where Ink renders your app. 20 | */ 21 | // eslint-disable-next-line @typescript-eslint/naming-convention 22 | const StdoutContext = createContext({ 23 | stdout: process.stdout, 24 | write() {}, 25 | }); 26 | 27 | StdoutContext.displayName = 'InternalStdoutContext'; 28 | 29 | export default StdoutContext; 30 | -------------------------------------------------------------------------------- /src/components/Text.tsx: -------------------------------------------------------------------------------- 1 | import React, {type ReactNode} from 'react'; 2 | import chalk, {type ForegroundColorName} from 'chalk'; 3 | import {type LiteralUnion} from 'type-fest'; 4 | import colorize from '../colorize.js'; 5 | import {type Styles} from '../styles.js'; 6 | 7 | export type Props = { 8 | /** 9 | * Change text color. Ink uses chalk under the hood, so all its functionality is supported. 10 | */ 11 | readonly color?: LiteralUnion; 12 | 13 | /** 14 | * Same as `color`, but for background. 15 | */ 16 | readonly backgroundColor?: LiteralUnion; 17 | 18 | /** 19 | * Dim the color (emit a small amount of light). 20 | */ 21 | readonly dimColor?: boolean; 22 | 23 | /** 24 | * Make the text bold. 25 | */ 26 | readonly bold?: boolean; 27 | 28 | /** 29 | * Make the text italic. 30 | */ 31 | readonly italic?: boolean; 32 | 33 | /** 34 | * Make the text underlined. 35 | */ 36 | readonly underline?: boolean; 37 | 38 | /** 39 | * Make the text crossed with a line. 40 | */ 41 | readonly strikethrough?: boolean; 42 | 43 | /** 44 | * Inverse background and foreground colors. 45 | */ 46 | readonly inverse?: boolean; 47 | 48 | /** 49 | * This property tells Ink to wrap or truncate text if its width is larger than container. 50 | * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines. 51 | * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off. 52 | */ 53 | readonly wrap?: Styles['textWrap']; 54 | 55 | readonly children?: ReactNode; 56 | }; 57 | 58 | /** 59 | * This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough. 60 | */ 61 | export default function Text({ 62 | color, 63 | backgroundColor, 64 | dimColor = false, 65 | bold = false, 66 | italic = false, 67 | underline = false, 68 | strikethrough = false, 69 | inverse = false, 70 | wrap = 'wrap', 71 | children, 72 | }: Props) { 73 | if (children === undefined || children === null) { 74 | return null; 75 | } 76 | 77 | const transform = (children: string): string => { 78 | if (dimColor) { 79 | children = chalk.dim(children); 80 | } 81 | 82 | if (color) { 83 | children = colorize(children, color, 'foreground'); 84 | } 85 | 86 | if (backgroundColor) { 87 | children = colorize(children, backgroundColor, 'background'); 88 | } 89 | 90 | if (bold) { 91 | children = chalk.bold(children); 92 | } 93 | 94 | if (italic) { 95 | children = chalk.italic(children); 96 | } 97 | 98 | if (underline) { 99 | children = chalk.underline(children); 100 | } 101 | 102 | if (strikethrough) { 103 | children = chalk.strikethrough(children); 104 | } 105 | 106 | if (inverse) { 107 | children = chalk.inverse(children); 108 | } 109 | 110 | return children; 111 | }; 112 | 113 | return ( 114 | 118 | {children} 119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /src/components/Transform.tsx: -------------------------------------------------------------------------------- 1 | import React, {type ReactNode} from 'react'; 2 | 3 | export type Props = { 4 | /** 5 | * Function which transforms children output. It accepts children and must return transformed children too. 6 | */ 7 | readonly transform: (children: string, index: number) => string; 8 | 9 | readonly children?: ReactNode; 10 | }; 11 | 12 | /** 13 | * Transform a string representation of React components before they are written to output. 14 | * For example, you might want to apply a gradient to text, add a clickable link or create some text effects. 15 | * These use cases can't accept React nodes as input, they are expecting a string. 16 | * That's what component does, it gives you an output string of its child components and lets you transform it in any way. 17 | */ 18 | export default function Transform({children, transform}: Props) { 19 | if (children === undefined || children === null) { 20 | return null; 21 | } 22 | 23 | return ( 24 | 28 | {children} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/devtools-window-polyfill.ts: -------------------------------------------------------------------------------- 1 | // Ignoring missing types error to avoid adding another dependency for this hack to work 2 | import ws from 'ws'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 5 | const customGlobal = global as any; 6 | 7 | // These things must exist before importing `react-devtools-core` 8 | 9 | // eslint-disable-next-line n/no-unsupported-features/node-builtins 10 | customGlobal.WebSocket ||= ws; 11 | 12 | customGlobal.window ||= global; 13 | 14 | customGlobal.self ||= global; 15 | 16 | // Filter out Ink's internal components from devtools for a cleaner view. 17 | // Also, ince `react-devtools-shared` package isn't published on npm, we can't 18 | // use its types, that's why there are hard-coded values in `type` fields below. 19 | // See https://github.com/facebook/react/blob/edf6eac8a181860fd8a2d076a43806f1237495a1/packages/react-devtools-shared/src/types.js#L24 20 | customGlobal.window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = [ 21 | { 22 | // ComponentFilterElementType 23 | type: 1, 24 | // ElementTypeHostComponent 25 | value: 7, 26 | isEnabled: true, 27 | }, 28 | { 29 | // ComponentFilterDisplayName 30 | type: 2, 31 | value: 'InternalApp', 32 | isEnabled: true, 33 | isValid: true, 34 | }, 35 | { 36 | // ComponentFilterDisplayName 37 | type: 2, 38 | value: 'InternalAppContext', 39 | isEnabled: true, 40 | isValid: true, 41 | }, 42 | { 43 | // ComponentFilterDisplayName 44 | type: 2, 45 | value: 'InternalStdoutContext', 46 | isEnabled: true, 47 | isValid: true, 48 | }, 49 | { 50 | // ComponentFilterDisplayName 51 | type: 2, 52 | value: 'InternalStderrContext', 53 | isEnabled: true, 54 | isValid: true, 55 | }, 56 | { 57 | // ComponentFilterDisplayName 58 | type: 2, 59 | value: 'InternalStdinContext', 60 | isEnabled: true, 61 | isValid: true, 62 | }, 63 | { 64 | // ComponentFilterDisplayName 65 | type: 2, 66 | value: 'InternalFocusContext', 67 | isEnabled: true, 68 | isValid: true, 69 | }, 70 | ]; 71 | -------------------------------------------------------------------------------- /src/devtools.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/order */ 2 | 3 | // eslint-disable-next-line import/no-unassigned-import 4 | import './devtools-window-polyfill.js'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-expect-error 8 | import devtools from 'react-devtools-core'; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 11 | (devtools as any).connectToDevTools(); 12 | -------------------------------------------------------------------------------- /src/dom.ts: -------------------------------------------------------------------------------- 1 | import Yoga, {type Node as YogaNode} from 'yoga-layout'; 2 | import measureText from './measure-text.js'; 3 | import {type Styles} from './styles.js'; 4 | import wrapText from './wrap-text.js'; 5 | import squashTextNodes from './squash-text-nodes.js'; 6 | import {type OutputTransformer} from './render-node-to-output.js'; 7 | 8 | type InkNode = { 9 | parentNode: DOMElement | undefined; 10 | yogaNode?: YogaNode; 11 | internal_static?: boolean; 12 | style: Styles; 13 | }; 14 | 15 | export type TextName = '#text'; 16 | export type ElementNames = 17 | | 'ink-root' 18 | | 'ink-box' 19 | | 'ink-text' 20 | | 'ink-virtual-text'; 21 | 22 | export type NodeNames = ElementNames | TextName; 23 | 24 | // eslint-disable-next-line @typescript-eslint/naming-convention 25 | export type DOMElement = { 26 | nodeName: ElementNames; 27 | attributes: Record; 28 | childNodes: DOMNode[]; 29 | internal_transform?: OutputTransformer; 30 | 31 | // Internal properties 32 | isStaticDirty?: boolean; 33 | staticNode?: DOMElement; 34 | onComputeLayout?: () => void; 35 | onRender?: () => void; 36 | onImmediateRender?: () => void; 37 | } & InkNode; 38 | 39 | export type TextNode = { 40 | nodeName: TextName; 41 | nodeValue: string; 42 | } & InkNode; 43 | 44 | // eslint-disable-next-line @typescript-eslint/naming-convention 45 | export type DOMNode = T extends { 46 | nodeName: infer U; 47 | } 48 | ? U extends '#text' 49 | ? TextNode 50 | : DOMElement 51 | : never; 52 | 53 | // eslint-disable-next-line @typescript-eslint/naming-convention 54 | export type DOMNodeAttribute = boolean | string | number; 55 | 56 | export const createNode = (nodeName: ElementNames): DOMElement => { 57 | const node: DOMElement = { 58 | nodeName, 59 | style: {}, 60 | attributes: {}, 61 | childNodes: [], 62 | parentNode: undefined, 63 | yogaNode: nodeName === 'ink-virtual-text' ? undefined : Yoga.Node.create(), 64 | }; 65 | 66 | if (nodeName === 'ink-text') { 67 | node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node)); 68 | } 69 | 70 | return node; 71 | }; 72 | 73 | export const appendChildNode = ( 74 | node: DOMElement, 75 | childNode: DOMElement, 76 | ): void => { 77 | if (childNode.parentNode) { 78 | removeChildNode(childNode.parentNode, childNode); 79 | } 80 | 81 | childNode.parentNode = node; 82 | node.childNodes.push(childNode); 83 | 84 | if (childNode.yogaNode) { 85 | node.yogaNode?.insertChild( 86 | childNode.yogaNode, 87 | node.yogaNode.getChildCount(), 88 | ); 89 | } 90 | 91 | if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') { 92 | markNodeAsDirty(node); 93 | } 94 | }; 95 | 96 | export const insertBeforeNode = ( 97 | node: DOMElement, 98 | newChildNode: DOMNode, 99 | beforeChildNode: DOMNode, 100 | ): void => { 101 | if (newChildNode.parentNode) { 102 | removeChildNode(newChildNode.parentNode, newChildNode); 103 | } 104 | 105 | newChildNode.parentNode = node; 106 | 107 | const index = node.childNodes.indexOf(beforeChildNode); 108 | if (index >= 0) { 109 | node.childNodes.splice(index, 0, newChildNode); 110 | if (newChildNode.yogaNode) { 111 | node.yogaNode?.insertChild(newChildNode.yogaNode, index); 112 | } 113 | 114 | return; 115 | } 116 | 117 | node.childNodes.push(newChildNode); 118 | 119 | if (newChildNode.yogaNode) { 120 | node.yogaNode?.insertChild( 121 | newChildNode.yogaNode, 122 | node.yogaNode.getChildCount(), 123 | ); 124 | } 125 | 126 | if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') { 127 | markNodeAsDirty(node); 128 | } 129 | }; 130 | 131 | export const removeChildNode = ( 132 | node: DOMElement, 133 | removeNode: DOMNode, 134 | ): void => { 135 | if (removeNode.yogaNode) { 136 | removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode); 137 | } 138 | 139 | removeNode.parentNode = undefined; 140 | 141 | const index = node.childNodes.indexOf(removeNode); 142 | if (index >= 0) { 143 | node.childNodes.splice(index, 1); 144 | } 145 | 146 | if (node.nodeName === 'ink-text' || node.nodeName === 'ink-virtual-text') { 147 | markNodeAsDirty(node); 148 | } 149 | }; 150 | 151 | export const setAttribute = ( 152 | node: DOMElement, 153 | key: string, 154 | value: DOMNodeAttribute, 155 | ): void => { 156 | node.attributes[key] = value; 157 | }; 158 | 159 | export const setStyle = (node: DOMNode, style: Styles): void => { 160 | node.style = style; 161 | }; 162 | 163 | export const createTextNode = (text: string): TextNode => { 164 | const node: TextNode = { 165 | nodeName: '#text', 166 | nodeValue: text, 167 | yogaNode: undefined, 168 | parentNode: undefined, 169 | style: {}, 170 | }; 171 | 172 | setTextNodeValue(node, text); 173 | 174 | return node; 175 | }; 176 | 177 | const measureTextNode = function ( 178 | node: DOMNode, 179 | width: number, 180 | ): {width: number; height: number} { 181 | const text = 182 | node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node); 183 | 184 | const dimensions = measureText(text); 185 | 186 | // Text fits into container, no need to wrap 187 | if (dimensions.width <= width) { 188 | return dimensions; 189 | } 190 | 191 | // This is happening when is shrinking child nodes and Yoga asks 192 | // if we can fit this text node in a <1px space, so we just tell Yoga "no" 193 | if (dimensions.width >= 1 && width > 0 && width < 1) { 194 | return dimensions; 195 | } 196 | 197 | const textWrap = node.style?.textWrap ?? 'wrap'; 198 | const wrappedText = wrapText(text, width, textWrap); 199 | 200 | return measureText(wrappedText); 201 | }; 202 | 203 | const findClosestYogaNode = (node?: DOMNode): YogaNode | undefined => { 204 | if (!node?.parentNode) { 205 | return undefined; 206 | } 207 | 208 | return node.yogaNode ?? findClosestYogaNode(node.parentNode); 209 | }; 210 | 211 | const markNodeAsDirty = (node?: DOMNode): void => { 212 | // Mark closest Yoga node as dirty to measure text dimensions again 213 | const yogaNode = findClosestYogaNode(node); 214 | yogaNode?.markDirty(); 215 | }; 216 | 217 | export const setTextNodeValue = (node: TextNode, text: string): void => { 218 | if (typeof text !== 'string') { 219 | text = String(text); 220 | } 221 | 222 | node.nodeValue = text; 223 | markNodeAsDirty(node); 224 | }; 225 | -------------------------------------------------------------------------------- /src/get-max-width.ts: -------------------------------------------------------------------------------- 1 | import Yoga, {type Node as YogaNode} from 'yoga-layout'; 2 | 3 | const getMaxWidth = (yogaNode: YogaNode) => { 4 | return ( 5 | yogaNode.getComputedWidth() - 6 | yogaNode.getComputedPadding(Yoga.EDGE_LEFT) - 7 | yogaNode.getComputedPadding(Yoga.EDGE_RIGHT) - 8 | yogaNode.getComputedBorder(Yoga.EDGE_LEFT) - 9 | yogaNode.getComputedBorder(Yoga.EDGE_RIGHT) 10 | ); 11 | }; 12 | 13 | export default getMaxWidth; 14 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import {type ReactNode, type Key, type LegacyRef} from 'react'; 2 | import {type Except} from 'type-fest'; 3 | import {type DOMElement} from './dom.js'; 4 | import {type Styles} from './styles.js'; 5 | 6 | declare module 'react' { 7 | namespace JSX { 8 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions 9 | interface IntrinsicElements { 10 | 'ink-box': Ink.Box; 11 | 'ink-text': Ink.Text; 12 | } 13 | } 14 | } 15 | 16 | declare namespace Ink { 17 | type Box = { 18 | internal_static?: boolean; 19 | children?: ReactNode; 20 | key?: Key; 21 | ref?: LegacyRef; 22 | style?: Except; 23 | }; 24 | 25 | type Text = { 26 | children?: ReactNode; 27 | key?: Key; 28 | style?: Styles; 29 | 30 | // eslint-disable-next-line @typescript-eslint/naming-convention 31 | internal_transform?: (children: string, index: number) => string; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/hooks/use-app.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react'; 2 | import AppContext from '../components/AppContext.js'; 3 | 4 | /** 5 | * `useApp` is a React hook, which exposes a method to manually exit the app (unmount). 6 | */ 7 | const useApp = () => useContext(AppContext); 8 | export default useApp; 9 | -------------------------------------------------------------------------------- /src/hooks/use-focus-manager.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react'; 2 | import FocusContext, {type Props} from '../components/FocusContext.js'; 3 | 4 | type Output = { 5 | /** 6 | * Enable focus management for all components. 7 | */ 8 | enableFocus: Props['enableFocus']; 9 | 10 | /** 11 | * Disable focus management for all components. Currently active component (if there's one) will lose its focus. 12 | */ 13 | disableFocus: Props['disableFocus']; 14 | 15 | /** 16 | * Switch focus to the next focusable component. 17 | * If there's no active component right now, focus will be given to the first focusable component. 18 | * If active component is the last in the list of focusable components, focus will be switched to the first component. 19 | */ 20 | focusNext: Props['focusNext']; 21 | 22 | /** 23 | * Switch focus to the previous focusable component. 24 | * If there's no active component right now, focus will be given to the first focusable component. 25 | * If active component is the first in the list of focusable components, focus will be switched to the last component. 26 | */ 27 | focusPrevious: Props['focusPrevious']; 28 | 29 | /** 30 | * Switch focus to the element with provided `id`. 31 | * If there's no element with that `id`, focus will be given to the first focusable component. 32 | */ 33 | focus: Props['focus']; 34 | }; 35 | 36 | /** 37 | * This hook exposes methods to enable or disable focus management for all 38 | * components or manually switch focus to next or previous components. 39 | */ 40 | const useFocusManager = (): Output => { 41 | const focusContext = useContext(FocusContext); 42 | 43 | return { 44 | enableFocus: focusContext.enableFocus, 45 | disableFocus: focusContext.disableFocus, 46 | focusNext: focusContext.focusNext, 47 | focusPrevious: focusContext.focusPrevious, 48 | focus: focusContext.focus, 49 | }; 50 | }; 51 | 52 | export default useFocusManager; 53 | -------------------------------------------------------------------------------- /src/hooks/use-focus.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useContext, useMemo} from 'react'; 2 | import FocusContext from '../components/FocusContext.js'; 3 | import useStdin from './use-stdin.js'; 4 | 5 | type Input = { 6 | /** 7 | * Enable or disable this component's focus, while still maintaining its position in the list of focusable components. 8 | */ 9 | isActive?: boolean; 10 | 11 | /** 12 | * Auto focus this component, if there's no active (focused) component right now. 13 | */ 14 | autoFocus?: boolean; 15 | 16 | /** 17 | * Assign an ID to this component, so it can be programmatically focused with `focus(id)`. 18 | */ 19 | id?: string; 20 | }; 21 | 22 | type Output = { 23 | /** 24 | * Determines whether this component is focused or not. 25 | */ 26 | isFocused: boolean; 27 | 28 | /** 29 | * Allows focusing a specific element with the provided `id`. 30 | */ 31 | focus: (id: string) => void; 32 | }; 33 | 34 | /** 35 | * Component that uses `useFocus` hook becomes "focusable" to Ink, 36 | * so when user presses Tab, Ink will switch focus to this component. 37 | * If there are multiple components that execute `useFocus` hook, focus will be 38 | * given to them in the order that these components are rendered in. 39 | * This hook returns an object with `isFocused` boolean property, which 40 | * determines if this component is focused or not. 41 | */ 42 | const useFocus = ({ 43 | isActive = true, 44 | autoFocus = false, 45 | id: customId, 46 | }: Input = {}): Output => { 47 | const {isRawModeSupported, setRawMode} = useStdin(); 48 | const {activeId, add, remove, activate, deactivate, focus} = 49 | useContext(FocusContext); 50 | 51 | const id = useMemo(() => { 52 | return customId ?? Math.random().toString().slice(2, 7); 53 | }, [customId]); 54 | 55 | useEffect(() => { 56 | add(id, {autoFocus}); 57 | 58 | return () => { 59 | remove(id); 60 | }; 61 | }, [id, autoFocus]); 62 | 63 | useEffect(() => { 64 | if (isActive) { 65 | activate(id); 66 | } else { 67 | deactivate(id); 68 | } 69 | }, [isActive, id]); 70 | 71 | useEffect(() => { 72 | if (!isRawModeSupported || !isActive) { 73 | return; 74 | } 75 | 76 | setRawMode(true); 77 | 78 | return () => { 79 | setRawMode(false); 80 | }; 81 | }, [isActive]); 82 | 83 | return { 84 | isFocused: Boolean(id) && activeId === id, 85 | focus, 86 | }; 87 | }; 88 | 89 | export default useFocus; 90 | -------------------------------------------------------------------------------- /src/hooks/use-input.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | import parseKeypress, {nonAlphanumericKeys} from '../parse-keypress.js'; 3 | import reconciler from '../reconciler.js'; 4 | import useStdin from './use-stdin.js'; 5 | 6 | /** 7 | * Handy information about a key that was pressed. 8 | */ 9 | export type Key = { 10 | /** 11 | * Up arrow key was pressed. 12 | */ 13 | upArrow: boolean; 14 | 15 | /** 16 | * Down arrow key was pressed. 17 | */ 18 | downArrow: boolean; 19 | 20 | /** 21 | * Left arrow key was pressed. 22 | */ 23 | leftArrow: boolean; 24 | 25 | /** 26 | * Right arrow key was pressed. 27 | */ 28 | rightArrow: boolean; 29 | 30 | /** 31 | * Page Down key was pressed. 32 | */ 33 | pageDown: boolean; 34 | 35 | /** 36 | * Page Up key was pressed. 37 | */ 38 | pageUp: boolean; 39 | 40 | /** 41 | * Return (Enter) key was pressed. 42 | */ 43 | return: boolean; 44 | 45 | /** 46 | * Escape key was pressed. 47 | */ 48 | escape: boolean; 49 | 50 | /** 51 | * Ctrl key was pressed. 52 | */ 53 | ctrl: boolean; 54 | 55 | /** 56 | * Shift key was pressed. 57 | */ 58 | shift: boolean; 59 | 60 | /** 61 | * Tab key was pressed. 62 | */ 63 | tab: boolean; 64 | 65 | /** 66 | * Backspace key was pressed. 67 | */ 68 | backspace: boolean; 69 | 70 | /** 71 | * Delete key was pressed. 72 | */ 73 | delete: boolean; 74 | 75 | /** 76 | * [Meta key](https://en.wikipedia.org/wiki/Meta_key) was pressed. 77 | */ 78 | meta: boolean; 79 | }; 80 | 81 | type Handler = (input: string, key: Key) => void; 82 | 83 | type Options = { 84 | /** 85 | * Enable or disable capturing of user input. 86 | * Useful when there are multiple useInput hooks used at once to avoid handling the same input several times. 87 | * 88 | * @default true 89 | */ 90 | isActive?: boolean; 91 | }; 92 | 93 | /** 94 | * This hook is used for handling user input. 95 | * It's a more convenient alternative to using `StdinContext` and listening to `data` events. 96 | * The callback you pass to `useInput` is called for each character when user enters any input. 97 | * However, if 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`. 98 | * 99 | * ``` 100 | * import {useInput} from 'ink'; 101 | * 102 | * const UserInput = () => { 103 | * useInput((input, key) => { 104 | * if (input === 'q') { 105 | * // Exit program 106 | * } 107 | * 108 | * if (key.leftArrow) { 109 | * // Left arrow key pressed 110 | * } 111 | * }); 112 | * 113 | * return … 114 | * }; 115 | * ``` 116 | */ 117 | const useInput = (inputHandler: Handler, options: Options = {}) => { 118 | // eslint-disable-next-line @typescript-eslint/naming-convention 119 | const {stdin, setRawMode, internal_exitOnCtrlC, internal_eventEmitter} = 120 | useStdin(); 121 | 122 | useEffect(() => { 123 | if (options.isActive === false) { 124 | return; 125 | } 126 | 127 | setRawMode(true); 128 | 129 | return () => { 130 | setRawMode(false); 131 | }; 132 | }, [options.isActive, setRawMode]); 133 | 134 | useEffect(() => { 135 | if (options.isActive === false) { 136 | return; 137 | } 138 | 139 | const handleData = (data: string) => { 140 | const keypress = parseKeypress(data); 141 | 142 | const key = { 143 | upArrow: keypress.name === 'up', 144 | downArrow: keypress.name === 'down', 145 | leftArrow: keypress.name === 'left', 146 | rightArrow: keypress.name === 'right', 147 | pageDown: keypress.name === 'pagedown', 148 | pageUp: keypress.name === 'pageup', 149 | return: keypress.name === 'return', 150 | escape: keypress.name === 'escape', 151 | ctrl: keypress.ctrl, 152 | shift: keypress.shift, 153 | tab: keypress.name === 'tab', 154 | backspace: keypress.name === 'backspace', 155 | delete: keypress.name === 'delete', 156 | // `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false 157 | // but with option = true, so we need to take this into account here 158 | // to avoid breaking changes in Ink. 159 | // TODO(vadimdemedes): consider removing this in the next major version. 160 | meta: keypress.meta || keypress.name === 'escape' || keypress.option, 161 | }; 162 | 163 | let input = keypress.ctrl ? keypress.name : keypress.sequence; 164 | 165 | if (nonAlphanumericKeys.includes(keypress.name)) { 166 | input = ''; 167 | } 168 | 169 | // Strip meta if it's still remaining after `parseKeypress` 170 | // TODO(vadimdemedes): remove this in the next major version. 171 | if (input.startsWith('\u001B')) { 172 | input = input.slice(1); 173 | } 174 | 175 | if ( 176 | input.length === 1 && 177 | typeof input[0] === 'string' && 178 | /[A-Z]/.test(input[0]) 179 | ) { 180 | key.shift = true; 181 | } 182 | 183 | // If app is not supposed to exit on Ctrl+C, then let input listener handle it 184 | if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) { 185 | // @ts-expect-error TypeScript types for `batchedUpdates` require an argument, but React's codebase doesn't provide it and it works without it as exepected. 186 | reconciler.batchedUpdates(() => { 187 | inputHandler(input, key); 188 | }); 189 | } 190 | }; 191 | 192 | internal_eventEmitter?.on('input', handleData); 193 | 194 | return () => { 195 | internal_eventEmitter?.removeListener('input', handleData); 196 | }; 197 | }, [options.isActive, stdin, internal_exitOnCtrlC, inputHandler]); 198 | }; 199 | 200 | export default useInput; 201 | -------------------------------------------------------------------------------- /src/hooks/use-stderr.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react'; 2 | import StderrContext from '../components/StderrContext.js'; 3 | 4 | /** 5 | * `useStderr` is a React hook, which exposes stderr stream. 6 | */ 7 | const useStderr = () => useContext(StderrContext); 8 | export default useStderr; 9 | -------------------------------------------------------------------------------- /src/hooks/use-stdin.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react'; 2 | import StdinContext from '../components/StdinContext.js'; 3 | 4 | /** 5 | * `useStdin` is a React hook, which exposes stdin stream. 6 | */ 7 | const useStdin = () => useContext(StdinContext); 8 | export default useStdin; 9 | -------------------------------------------------------------------------------- /src/hooks/use-stdout.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react'; 2 | import StdoutContext from '../components/StdoutContext.js'; 3 | 4 | /** 5 | * `useStdout` is a React hook, which exposes stdout stream. 6 | */ 7 | const useStdout = () => useContext(StdoutContext); 8 | export default useStdout; 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type {RenderOptions, Instance} from './render.js'; 2 | export {default as render} from './render.js'; 3 | export type {Props as BoxProps} from './components/Box.js'; 4 | export {default as Box} from './components/Box.js'; 5 | export type {Props as TextProps} from './components/Text.js'; 6 | export {default as Text} from './components/Text.js'; 7 | export type {Props as AppProps} from './components/AppContext.js'; 8 | export type {Props as StdinProps} from './components/StdinContext.js'; 9 | export type {Props as StdoutProps} from './components/StdoutContext.js'; 10 | export type {Props as StderrProps} from './components/StderrContext.js'; 11 | export type {Props as StaticProps} from './components/Static.js'; 12 | export {default as Static} from './components/Static.js'; 13 | export type {Props as TransformProps} from './components/Transform.js'; 14 | export {default as Transform} from './components/Transform.js'; 15 | export type {Props as NewlineProps} from './components/Newline.js'; 16 | export {default as Newline} from './components/Newline.js'; 17 | export {default as Spacer} from './components/Spacer.js'; 18 | export type {Key} from './hooks/use-input.js'; 19 | export {default as useInput} from './hooks/use-input.js'; 20 | export {default as useApp} from './hooks/use-app.js'; 21 | export {default as useStdin} from './hooks/use-stdin.js'; 22 | export {default as useStdout} from './hooks/use-stdout.js'; 23 | export {default as useStderr} from './hooks/use-stderr.js'; 24 | export {default as useFocus} from './hooks/use-focus.js'; 25 | export {default as useFocusManager} from './hooks/use-focus-manager.js'; 26 | export {default as measureElement} from './measure-element.js'; 27 | export type {DOMElement} from './dom.js'; 28 | -------------------------------------------------------------------------------- /src/ink.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import React, {type ReactNode} from 'react'; 3 | import {throttle} from 'es-toolkit/compat'; 4 | import ansiEscapes from 'ansi-escapes'; 5 | import isInCi from 'is-in-ci'; 6 | import autoBind from 'auto-bind'; 7 | import signalExit from 'signal-exit'; 8 | import patchConsole from 'patch-console'; 9 | import {LegacyRoot} from 'react-reconciler/constants.js'; 10 | import {type FiberRoot} from 'react-reconciler'; 11 | import Yoga from 'yoga-layout'; 12 | import reconciler from './reconciler.js'; 13 | import render from './renderer.js'; 14 | import * as dom from './dom.js'; 15 | import logUpdate, {type LogUpdate} from './log-update.js'; 16 | import instances from './instances.js'; 17 | import App from './components/App.js'; 18 | 19 | const noop = () => {}; 20 | 21 | export type Options = { 22 | stdout: NodeJS.WriteStream; 23 | stdin: NodeJS.ReadStream; 24 | stderr: NodeJS.WriteStream; 25 | debug: boolean; 26 | exitOnCtrlC: boolean; 27 | patchConsole: boolean; 28 | waitUntilExit?: () => Promise; 29 | }; 30 | 31 | export default class Ink { 32 | private readonly options: Options; 33 | private readonly log: LogUpdate; 34 | private readonly throttledLog: LogUpdate; 35 | // Ignore last render after unmounting a tree to prevent empty output before exit 36 | private isUnmounted: boolean; 37 | private lastOutput: string; 38 | private lastOutputHeight: number; 39 | private readonly container: FiberRoot; 40 | private readonly rootNode: dom.DOMElement; 41 | // This variable is used only in debug mode to store full static output 42 | // so that it's rerendered every time, not just new static parts, like in non-debug mode 43 | private fullStaticOutput: string; 44 | private exitPromise?: Promise; 45 | private restoreConsole?: () => void; 46 | private readonly unsubscribeResize?: () => void; 47 | 48 | constructor(options: Options) { 49 | autoBind(this); 50 | 51 | this.options = options; 52 | this.rootNode = dom.createNode('ink-root'); 53 | this.rootNode.onComputeLayout = this.calculateLayout; 54 | 55 | this.rootNode.onRender = options.debug 56 | ? this.onRender 57 | : throttle(this.onRender, 32, { 58 | leading: true, 59 | trailing: true, 60 | }); 61 | 62 | this.rootNode.onImmediateRender = this.onRender; 63 | this.log = logUpdate.create(options.stdout); 64 | this.throttledLog = options.debug 65 | ? this.log 66 | : (throttle(this.log, undefined, { 67 | leading: true, 68 | trailing: true, 69 | }) as unknown as LogUpdate); 70 | 71 | // Ignore last render after unmounting a tree to prevent empty output before exit 72 | this.isUnmounted = false; 73 | 74 | // Store last output to only rerender when needed 75 | this.lastOutput = ''; 76 | this.lastOutputHeight = 0; 77 | 78 | // This variable is used only in debug mode to store full static output 79 | // so that it's rerendered every time, not just new static parts, like in non-debug mode 80 | this.fullStaticOutput = ''; 81 | 82 | // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment 83 | this.container = reconciler.createContainer( 84 | this.rootNode, 85 | LegacyRoot, 86 | null, 87 | false, 88 | null, 89 | 'id', 90 | () => {}, 91 | () => {}, 92 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library. 93 | // See https://github.com/facebook/react/blob/c0464aedb16b1c970d717651bba8d1c66c578729/packages/react-reconciler/src/ReactFiberReconciler.js#L236-L259 94 | () => {}, 95 | () => {}, 96 | null, 97 | ); 98 | 99 | // Unmount when process exits 100 | this.unsubscribeExit = signalExit(this.unmount, {alwaysLast: false}); 101 | 102 | if (process.env['DEV'] === 'true') { 103 | reconciler.injectIntoDevTools({ 104 | bundleType: 0, 105 | // Reporting React DOM's version, not Ink's 106 | // See https://github.com/facebook/react/issues/16666#issuecomment-532639905 107 | version: '16.13.1', 108 | rendererPackageName: 'ink', 109 | }); 110 | } 111 | 112 | if (options.patchConsole) { 113 | this.patchConsole(); 114 | } 115 | 116 | if (!isInCi) { 117 | options.stdout.on('resize', this.resized); 118 | 119 | this.unsubscribeResize = () => { 120 | options.stdout.off('resize', this.resized); 121 | }; 122 | } 123 | } 124 | 125 | resized = () => { 126 | this.calculateLayout(); 127 | this.onRender(); 128 | }; 129 | 130 | resolveExitPromise: () => void = () => {}; 131 | rejectExitPromise: (reason?: Error) => void = () => {}; 132 | unsubscribeExit: () => void = () => {}; 133 | 134 | calculateLayout = () => { 135 | // The 'columns' property can be undefined or 0 when not using a TTY. 136 | // In that case we fall back to 80. 137 | const terminalWidth = this.options.stdout.columns || 80; 138 | 139 | this.rootNode.yogaNode!.setWidth(terminalWidth); 140 | 141 | this.rootNode.yogaNode!.calculateLayout( 142 | undefined, 143 | undefined, 144 | Yoga.DIRECTION_LTR, 145 | ); 146 | }; 147 | 148 | onRender: () => void = () => { 149 | if (this.isUnmounted) { 150 | return; 151 | } 152 | 153 | const {output, outputHeight, staticOutput} = render(this.rootNode); 154 | 155 | // If output isn't empty, it means new children have been added to it 156 | const hasStaticOutput = staticOutput && staticOutput !== '\n'; 157 | 158 | if (this.options.debug) { 159 | if (hasStaticOutput) { 160 | this.fullStaticOutput += staticOutput; 161 | } 162 | 163 | this.options.stdout.write(this.fullStaticOutput + output); 164 | return; 165 | } 166 | 167 | if (isInCi) { 168 | if (hasStaticOutput) { 169 | this.options.stdout.write(staticOutput); 170 | } 171 | 172 | this.lastOutput = output; 173 | this.lastOutputHeight = outputHeight; 174 | return; 175 | } 176 | 177 | if (hasStaticOutput) { 178 | this.fullStaticOutput += staticOutput; 179 | } 180 | 181 | if (this.lastOutputHeight >= this.options.stdout.rows) { 182 | this.options.stdout.write( 183 | ansiEscapes.clearTerminal + this.fullStaticOutput + output + '\n', 184 | ); 185 | this.lastOutput = output; 186 | this.lastOutputHeight = outputHeight; 187 | this.log.sync(output); 188 | return; 189 | } 190 | 191 | // To ensure static output is cleanly rendered before main output, clear main output first 192 | if (hasStaticOutput) { 193 | this.log.clear(); 194 | this.options.stdout.write(staticOutput); 195 | this.log(output); 196 | } 197 | 198 | if (!hasStaticOutput && output !== this.lastOutput) { 199 | this.throttledLog(output); 200 | } 201 | 202 | this.lastOutput = output; 203 | this.lastOutputHeight = outputHeight; 204 | }; 205 | 206 | render(node: ReactNode): void { 207 | const tree = ( 208 | 217 | {node} 218 | 219 | ); 220 | 221 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library. 222 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 223 | reconciler.updateContainerSync(tree, this.container, null, noop); 224 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library. 225 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 226 | reconciler.flushSyncWork(); 227 | } 228 | 229 | writeToStdout(data: string): void { 230 | if (this.isUnmounted) { 231 | return; 232 | } 233 | 234 | if (this.options.debug) { 235 | this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput); 236 | return; 237 | } 238 | 239 | if (isInCi) { 240 | this.options.stdout.write(data); 241 | return; 242 | } 243 | 244 | this.log.clear(); 245 | this.options.stdout.write(data); 246 | this.log(this.lastOutput); 247 | } 248 | 249 | writeToStderr(data: string): void { 250 | if (this.isUnmounted) { 251 | return; 252 | } 253 | 254 | if (this.options.debug) { 255 | this.options.stderr.write(data); 256 | this.options.stdout.write(this.fullStaticOutput + this.lastOutput); 257 | return; 258 | } 259 | 260 | if (isInCi) { 261 | this.options.stderr.write(data); 262 | return; 263 | } 264 | 265 | this.log.clear(); 266 | this.options.stderr.write(data); 267 | this.log(this.lastOutput); 268 | } 269 | 270 | // eslint-disable-next-line @typescript-eslint/ban-types 271 | unmount(error?: Error | number | null): void { 272 | if (this.isUnmounted) { 273 | return; 274 | } 275 | 276 | this.calculateLayout(); 277 | this.onRender(); 278 | this.unsubscribeExit(); 279 | 280 | if (typeof this.restoreConsole === 'function') { 281 | this.restoreConsole(); 282 | } 283 | 284 | if (typeof this.unsubscribeResize === 'function') { 285 | this.unsubscribeResize(); 286 | } 287 | 288 | // CIs don't handle erasing ansi escapes well, so it's better to 289 | // only render last frame of non-static output 290 | if (isInCi) { 291 | this.options.stdout.write(this.lastOutput + '\n'); 292 | } else if (!this.options.debug) { 293 | this.log.done(); 294 | } 295 | 296 | this.isUnmounted = true; 297 | 298 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library. 299 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 300 | reconciler.updateContainerSync(null, this.container, null, noop); 301 | // @ts-expect-error the types for `react-reconciler` are not up to date with the library. 302 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 303 | reconciler.flushSyncWork(); 304 | instances.delete(this.options.stdout); 305 | 306 | if (error instanceof Error) { 307 | this.rejectExitPromise(error); 308 | } else { 309 | this.resolveExitPromise(); 310 | } 311 | } 312 | 313 | async waitUntilExit(): Promise { 314 | this.exitPromise ||= new Promise((resolve, reject) => { 315 | this.resolveExitPromise = resolve; 316 | this.rejectExitPromise = reject; 317 | }); 318 | 319 | return this.exitPromise; 320 | } 321 | 322 | clear(): void { 323 | if (!isInCi && !this.options.debug) { 324 | this.log.clear(); 325 | } 326 | } 327 | 328 | patchConsole(): void { 329 | if (this.options.debug) { 330 | return; 331 | } 332 | 333 | this.restoreConsole = patchConsole((stream, data) => { 334 | if (stream === 'stdout') { 335 | this.writeToStdout(data); 336 | } 337 | 338 | if (stream === 'stderr') { 339 | const isReactMessage = data.startsWith('The above error occurred'); 340 | 341 | if (!isReactMessage) { 342 | this.writeToStderr(data); 343 | } 344 | } 345 | }); 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /src/instances.ts: -------------------------------------------------------------------------------- 1 | // Store all instances of Ink (instance.js) to ensure that consecutive render() calls 2 | // use the same instance of Ink and don't create a new one 3 | // 4 | // This map has to be stored in a separate file, because render.js creates instances, 5 | // but instance.js should delete itself from the map on unmount 6 | 7 | import type Ink from './ink.js'; 8 | 9 | const instances = new WeakMap(); 10 | export default instances; 11 | -------------------------------------------------------------------------------- /src/log-update.ts: -------------------------------------------------------------------------------- 1 | import {type Writable} from 'node:stream'; 2 | import ansiEscapes from 'ansi-escapes'; 3 | import cliCursor from 'cli-cursor'; 4 | 5 | export type LogUpdate = { 6 | clear: () => void; 7 | done: () => void; 8 | sync: (str: string) => void; 9 | (str: string): void; 10 | }; 11 | 12 | const create = (stream: Writable, {showCursor = false} = {}): LogUpdate => { 13 | let previousLineCount = 0; 14 | let previousOutput = ''; 15 | let hasHiddenCursor = false; 16 | 17 | const render = (str: string) => { 18 | if (!showCursor && !hasHiddenCursor) { 19 | cliCursor.hide(); 20 | hasHiddenCursor = true; 21 | } 22 | 23 | const output = str + '\n'; 24 | if (output === previousOutput) { 25 | return; 26 | } 27 | 28 | previousOutput = output; 29 | stream.write(ansiEscapes.eraseLines(previousLineCount) + output); 30 | previousLineCount = output.split('\n').length; 31 | }; 32 | 33 | render.clear = () => { 34 | stream.write(ansiEscapes.eraseLines(previousLineCount)); 35 | previousOutput = ''; 36 | previousLineCount = 0; 37 | }; 38 | 39 | render.done = () => { 40 | previousOutput = ''; 41 | previousLineCount = 0; 42 | 43 | if (!showCursor) { 44 | cliCursor.show(); 45 | hasHiddenCursor = false; 46 | } 47 | }; 48 | 49 | render.sync = (str: string) => { 50 | const output = str + '\n'; 51 | previousOutput = output; 52 | previousLineCount = output.split('\n').length; 53 | }; 54 | 55 | return render; 56 | }; 57 | 58 | const logUpdate = {create}; 59 | export default logUpdate; 60 | -------------------------------------------------------------------------------- /src/measure-element.ts: -------------------------------------------------------------------------------- 1 | import {type DOMElement} from './dom.js'; 2 | 3 | type Output = { 4 | /** 5 | * Element width. 6 | */ 7 | width: number; 8 | 9 | /** 10 | * Element height. 11 | */ 12 | height: number; 13 | }; 14 | 15 | /** 16 | * Measure the dimensions of a particular `` element. 17 | */ 18 | const measureElement = (node: DOMElement): Output => ({ 19 | width: node.yogaNode?.getComputedWidth() ?? 0, 20 | height: node.yogaNode?.getComputedHeight() ?? 0, 21 | }); 22 | 23 | export default measureElement; 24 | -------------------------------------------------------------------------------- /src/measure-text.ts: -------------------------------------------------------------------------------- 1 | import widestLine from 'widest-line'; 2 | 3 | const cache: Record = {}; 4 | 5 | type Output = { 6 | width: number; 7 | height: number; 8 | }; 9 | 10 | const measureText = (text: string): Output => { 11 | if (text.length === 0) { 12 | return { 13 | width: 0, 14 | height: 0, 15 | }; 16 | } 17 | 18 | const cachedDimensions = cache[text]; 19 | 20 | if (cachedDimensions) { 21 | return cachedDimensions; 22 | } 23 | 24 | const width = widestLine(text); 25 | const height = text.split('\n').length; 26 | cache[text] = {width, height}; 27 | 28 | return {width, height}; 29 | }; 30 | 31 | export default measureText; 32 | -------------------------------------------------------------------------------- /src/output.ts: -------------------------------------------------------------------------------- 1 | import sliceAnsi from 'slice-ansi'; 2 | import stringWidth from 'string-width'; 3 | import widestLine from 'widest-line'; 4 | import { 5 | type StyledChar, 6 | styledCharsFromTokens, 7 | styledCharsToString, 8 | tokenize, 9 | } from '@alcalzone/ansi-tokenize'; 10 | import {type OutputTransformer} from './render-node-to-output.js'; 11 | 12 | /** 13 | * "Virtual" output class 14 | * 15 | * Handles the positioning and saving of the output of each node in the tree. 16 | * Also responsible for applying transformations to each character of the output. 17 | * 18 | * Used to generate the final output of all nodes before writing it to actual output stream (e.g. stdout) 19 | */ 20 | 21 | type Options = { 22 | width: number; 23 | height: number; 24 | }; 25 | 26 | type Operation = WriteOperation | ClipOperation | UnclipOperation; 27 | 28 | type WriteOperation = { 29 | type: 'write'; 30 | x: number; 31 | y: number; 32 | text: string; 33 | transformers: OutputTransformer[]; 34 | }; 35 | 36 | type ClipOperation = { 37 | type: 'clip'; 38 | clip: Clip; 39 | }; 40 | 41 | type Clip = { 42 | x1: number | undefined; 43 | x2: number | undefined; 44 | y1: number | undefined; 45 | y2: number | undefined; 46 | }; 47 | 48 | type UnclipOperation = { 49 | type: 'unclip'; 50 | }; 51 | 52 | export default class Output { 53 | width: number; 54 | height: number; 55 | 56 | private readonly operations: Operation[] = []; 57 | 58 | constructor(options: Options) { 59 | const {width, height} = options; 60 | 61 | this.width = width; 62 | this.height = height; 63 | } 64 | 65 | write( 66 | x: number, 67 | y: number, 68 | text: string, 69 | options: {transformers: OutputTransformer[]}, 70 | ): void { 71 | const {transformers} = options; 72 | 73 | if (!text) { 74 | return; 75 | } 76 | 77 | this.operations.push({ 78 | type: 'write', 79 | x, 80 | y, 81 | text, 82 | transformers, 83 | }); 84 | } 85 | 86 | clip(clip: Clip) { 87 | this.operations.push({ 88 | type: 'clip', 89 | clip, 90 | }); 91 | } 92 | 93 | unclip() { 94 | this.operations.push({ 95 | type: 'unclip', 96 | }); 97 | } 98 | 99 | get(): {output: string; height: number} { 100 | // Initialize output array with a specific set of rows, so that margin/padding at the bottom is preserved 101 | const output: StyledChar[][] = []; 102 | 103 | for (let y = 0; y < this.height; y++) { 104 | const row: StyledChar[] = []; 105 | 106 | for (let x = 0; x < this.width; x++) { 107 | row.push({ 108 | type: 'char', 109 | value: ' ', 110 | fullWidth: false, 111 | styles: [], 112 | }); 113 | } 114 | 115 | output.push(row); 116 | } 117 | 118 | const clips: Clip[] = []; 119 | 120 | for (const operation of this.operations) { 121 | if (operation.type === 'clip') { 122 | clips.push(operation.clip); 123 | } 124 | 125 | if (operation.type === 'unclip') { 126 | clips.pop(); 127 | } 128 | 129 | if (operation.type === 'write') { 130 | const {text, transformers} = operation; 131 | let {x, y} = operation; 132 | let lines = text.split('\n'); 133 | 134 | const clip = clips.at(-1); 135 | 136 | if (clip) { 137 | const clipHorizontally = 138 | typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number'; 139 | 140 | const clipVertically = 141 | typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number'; 142 | 143 | // If text is positioned outside of clipping area altogether, 144 | // skip to the next operation to avoid unnecessary calculations 145 | if (clipHorizontally) { 146 | const width = widestLine(text); 147 | 148 | if (x + width < clip.x1! || x > clip.x2!) { 149 | continue; 150 | } 151 | } 152 | 153 | if (clipVertically) { 154 | const height = lines.length; 155 | 156 | if (y + height < clip.y1! || y > clip.y2!) { 157 | continue; 158 | } 159 | } 160 | 161 | if (clipHorizontally) { 162 | lines = lines.map(line => { 163 | const from = x < clip.x1! ? clip.x1! - x : 0; 164 | const width = stringWidth(line); 165 | const to = x + width > clip.x2! ? clip.x2! - x : width; 166 | 167 | return sliceAnsi(line, from, to); 168 | }); 169 | 170 | if (x < clip.x1!) { 171 | x = clip.x1!; 172 | } 173 | } 174 | 175 | if (clipVertically) { 176 | const from = y < clip.y1! ? clip.y1! - y : 0; 177 | const height = lines.length; 178 | const to = y + height > clip.y2! ? clip.y2! - y : height; 179 | 180 | lines = lines.slice(from, to); 181 | 182 | if (y < clip.y1!) { 183 | y = clip.y1!; 184 | } 185 | } 186 | } 187 | 188 | let offsetY = 0; 189 | 190 | for (let [index, line] of lines.entries()) { 191 | const currentLine = output[y + offsetY]; 192 | 193 | // Line can be missing if `text` is taller than height of pre-initialized `this.output` 194 | if (!currentLine) { 195 | continue; 196 | } 197 | 198 | for (const transformer of transformers) { 199 | line = transformer(line, index); 200 | } 201 | 202 | const characters = styledCharsFromTokens(tokenize(line)); 203 | let offsetX = x; 204 | 205 | for (const character of characters) { 206 | currentLine[offsetX] = character; 207 | 208 | // Some characters take up more than one column. In that case, the following 209 | // pixels need to be cleared to avoid printing extra characters 210 | const isWideCharacter = 211 | character.fullWidth || character.value.length > 1; 212 | 213 | if (isWideCharacter) { 214 | currentLine[offsetX + 1] = { 215 | type: 'char', 216 | value: '', 217 | fullWidth: false, 218 | styles: character.styles, 219 | }; 220 | } 221 | 222 | offsetX += isWideCharacter ? 2 : 1; 223 | } 224 | 225 | offsetY++; 226 | } 227 | } 228 | } 229 | 230 | const generatedOutput = output 231 | .map(line => { 232 | // See https://github.com/vadimdemedes/ink/pull/564#issuecomment-1637022742 233 | const lineWithoutEmptyItems = line.filter(item => item !== undefined); 234 | 235 | return styledCharsToString(lineWithoutEmptyItems).trimEnd(); 236 | }) 237 | .join('\n'); 238 | 239 | return { 240 | output: generatedOutput, 241 | height: output.length, 242 | }; 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/parse-keypress.ts: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/enquirer/enquirer/blob/36785f3399a41cd61e9d28d1eb9c2fcd73d69b4c/lib/keypress.js 2 | import {Buffer} from 'node:buffer'; 3 | 4 | const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/; 5 | 6 | const fnKeyRe = 7 | /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/; 8 | 9 | const keyName: Record = { 10 | /* xterm/gnome ESC O letter */ 11 | OP: 'f1', 12 | OQ: 'f2', 13 | OR: 'f3', 14 | OS: 'f4', 15 | /* xterm/rxvt ESC [ number ~ */ 16 | '[11~': 'f1', 17 | '[12~': 'f2', 18 | '[13~': 'f3', 19 | '[14~': 'f4', 20 | /* from Cygwin and used in libuv */ 21 | '[[A': 'f1', 22 | '[[B': 'f2', 23 | '[[C': 'f3', 24 | '[[D': 'f4', 25 | '[[E': 'f5', 26 | /* common */ 27 | '[15~': 'f5', 28 | '[17~': 'f6', 29 | '[18~': 'f7', 30 | '[19~': 'f8', 31 | '[20~': 'f9', 32 | '[21~': 'f10', 33 | '[23~': 'f11', 34 | '[24~': 'f12', 35 | /* xterm ESC [ letter */ 36 | '[A': 'up', 37 | '[B': 'down', 38 | '[C': 'right', 39 | '[D': 'left', 40 | '[E': 'clear', 41 | '[F': 'end', 42 | '[H': 'home', 43 | /* xterm/gnome ESC O letter */ 44 | OA: 'up', 45 | OB: 'down', 46 | OC: 'right', 47 | OD: 'left', 48 | OE: 'clear', 49 | OF: 'end', 50 | OH: 'home', 51 | /* xterm/rxvt ESC [ number ~ */ 52 | '[1~': 'home', 53 | '[2~': 'insert', 54 | '[3~': 'delete', 55 | '[4~': 'end', 56 | '[5~': 'pageup', 57 | '[6~': 'pagedown', 58 | /* putty */ 59 | '[[5~': 'pageup', 60 | '[[6~': 'pagedown', 61 | /* rxvt */ 62 | '[7~': 'home', 63 | '[8~': 'end', 64 | /* rxvt keys with modifiers */ 65 | '[a': 'up', 66 | '[b': 'down', 67 | '[c': 'right', 68 | '[d': 'left', 69 | '[e': 'clear', 70 | 71 | '[2$': 'insert', 72 | '[3$': 'delete', 73 | '[5$': 'pageup', 74 | '[6$': 'pagedown', 75 | '[7$': 'home', 76 | '[8$': 'end', 77 | 78 | Oa: 'up', 79 | Ob: 'down', 80 | Oc: 'right', 81 | Od: 'left', 82 | Oe: 'clear', 83 | 84 | '[2^': 'insert', 85 | '[3^': 'delete', 86 | '[5^': 'pageup', 87 | '[6^': 'pagedown', 88 | '[7^': 'home', 89 | '[8^': 'end', 90 | /* misc. */ 91 | '[Z': 'tab', 92 | }; 93 | 94 | export const nonAlphanumericKeys = [...Object.values(keyName), 'backspace']; 95 | 96 | const isShiftKey = (code: string) => { 97 | return [ 98 | '[a', 99 | '[b', 100 | '[c', 101 | '[d', 102 | '[e', 103 | '[2$', 104 | '[3$', 105 | '[5$', 106 | '[6$', 107 | '[7$', 108 | '[8$', 109 | '[Z', 110 | ].includes(code); 111 | }; 112 | 113 | const isCtrlKey = (code: string) => { 114 | return [ 115 | 'Oa', 116 | 'Ob', 117 | 'Oc', 118 | 'Od', 119 | 'Oe', 120 | '[2^', 121 | '[3^', 122 | '[5^', 123 | '[6^', 124 | '[7^', 125 | '[8^', 126 | ].includes(code); 127 | }; 128 | 129 | type ParsedKey = { 130 | name: string; 131 | ctrl: boolean; 132 | meta: boolean; 133 | shift: boolean; 134 | option: boolean; 135 | sequence: string; 136 | raw: string | undefined; 137 | code?: string; 138 | }; 139 | 140 | const parseKeypress = (s: Buffer | string = ''): ParsedKey => { 141 | let parts; 142 | 143 | if (Buffer.isBuffer(s)) { 144 | if (s[0]! > 127 && s[1] === undefined) { 145 | (s[0] as unknown as number) -= 128; 146 | s = '\x1b' + String(s); 147 | } else { 148 | s = String(s); 149 | } 150 | } else if (s !== undefined && typeof s !== 'string') { 151 | s = String(s); 152 | } else if (!s) { 153 | s = ''; 154 | } 155 | 156 | const key: ParsedKey = { 157 | name: '', 158 | ctrl: false, 159 | meta: false, 160 | shift: false, 161 | option: false, 162 | sequence: s, 163 | raw: s, 164 | }; 165 | 166 | key.sequence = key.sequence || s || key.name; 167 | 168 | if (s === '\r') { 169 | // carriage return 170 | key.raw = undefined; 171 | key.name = 'return'; 172 | } else if (s === '\n') { 173 | // enter, should have been called linefeed 174 | key.name = 'enter'; 175 | } else if (s === '\t') { 176 | // tab 177 | key.name = 'tab'; 178 | } else if (s === '\b' || s === '\x1b\b') { 179 | // backspace or ctrl+h 180 | key.name = 'backspace'; 181 | key.meta = s.charAt(0) === '\x1b'; 182 | } else if (s === '\x7f' || s === '\x1b\x7f') { 183 | // 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. 184 | // delete 185 | key.name = 'delete'; 186 | key.meta = s.charAt(0) === '\x1b'; 187 | } else if (s === '\x1b' || s === '\x1b\x1b') { 188 | // escape key 189 | key.name = 'escape'; 190 | key.meta = s.length === 2; 191 | } else if (s === ' ' || s === '\x1b ') { 192 | key.name = 'space'; 193 | key.meta = s.length === 2; 194 | } else if (s.length === 1 && s <= '\x1a') { 195 | // ctrl+letter 196 | key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1); 197 | key.ctrl = true; 198 | } else if (s.length === 1 && s >= '0' && s <= '9') { 199 | // number 200 | key.name = 'number'; 201 | } else if (s.length === 1 && s >= 'a' && s <= 'z') { 202 | // lowercase letter 203 | key.name = s; 204 | } else if (s.length === 1 && s >= 'A' && s <= 'Z') { 205 | // shift+letter 206 | key.name = s.toLowerCase(); 207 | key.shift = true; 208 | } else if ((parts = metaKeyCodeRe.exec(s))) { 209 | // meta+character key 210 | key.meta = true; 211 | key.shift = /^[A-Z]$/.test(parts[1]!); 212 | } else if ((parts = fnKeyRe.exec(s))) { 213 | const segs = [...s]; 214 | 215 | if (segs[0] === '\u001b' && segs[1] === '\u001b') { 216 | key.option = true; 217 | } 218 | 219 | // ansi escape sequence 220 | // reassemble the key code leaving out leading \x1b's, 221 | // the modifier key bitflag and any meaningless "1;" sequence 222 | const code = [parts[1], parts[2], parts[4], parts[6]] 223 | .filter(Boolean) 224 | .join(''); 225 | 226 | const modifier = ((parts[3] || parts[5] || 1) as number) - 1; 227 | 228 | // Parse the key modifier 229 | key.ctrl = !!(modifier & 4); 230 | key.meta = !!(modifier & 10); 231 | key.shift = !!(modifier & 1); 232 | key.code = code; 233 | 234 | key.name = keyName[code]!; 235 | key.shift = isShiftKey(code) || key.shift; 236 | key.ctrl = isCtrlKey(code) || key.ctrl; 237 | } 238 | 239 | return key; 240 | }; 241 | 242 | export default parseKeypress; 243 | -------------------------------------------------------------------------------- /src/reconciler.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import createReconciler, {type ReactContext} from 'react-reconciler'; 3 | import { 4 | DefaultEventPriority, 5 | NoEventPriority, 6 | } from 'react-reconciler/constants.js'; 7 | import Yoga, {type Node as YogaNode} from 'yoga-layout'; 8 | import {createContext} from 'react'; 9 | import { 10 | createTextNode, 11 | appendChildNode, 12 | insertBeforeNode, 13 | removeChildNode, 14 | setStyle, 15 | setTextNodeValue, 16 | createNode, 17 | setAttribute, 18 | type DOMNodeAttribute, 19 | type TextNode, 20 | type ElementNames, 21 | type DOMElement, 22 | } from './dom.js'; 23 | import applyStyles, {type Styles} from './styles.js'; 24 | import {type OutputTransformer} from './render-node-to-output.js'; 25 | 26 | // We need to conditionally perform devtools connection to avoid 27 | // accidentally breaking other third-party code. 28 | // See https://github.com/vadimdemedes/ink/issues/384 29 | if (process.env['DEV'] === 'true') { 30 | try { 31 | await import('./devtools.js'); 32 | } catch (error: any) { 33 | if (error.code === 'ERR_MODULE_NOT_FOUND') { 34 | console.warn( 35 | ` 36 | The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`, 37 | but this failed as it was not installed. Debugging with React Devtools requires it. 38 | 39 | To install use this command: 40 | 41 | $ npm install --save-dev react-devtools-core 42 | `.trim() + '\n', 43 | ); 44 | } else { 45 | // eslint-disable-next-line @typescript-eslint/only-throw-error 46 | throw error; 47 | } 48 | } 49 | } 50 | 51 | type AnyObject = Record; 52 | 53 | const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => { 54 | if (before === after) { 55 | return; 56 | } 57 | 58 | if (!before) { 59 | return after; 60 | } 61 | 62 | const changed: AnyObject = {}; 63 | let isChanged = false; 64 | 65 | for (const key of Object.keys(before)) { 66 | const isDeleted = after ? !Object.hasOwn(after, key) : true; 67 | 68 | if (isDeleted) { 69 | changed[key] = undefined; 70 | isChanged = true; 71 | } 72 | } 73 | 74 | if (after) { 75 | for (const key of Object.keys(after)) { 76 | if (after[key] !== before[key]) { 77 | changed[key] = after[key]; 78 | isChanged = true; 79 | } 80 | } 81 | } 82 | 83 | return isChanged ? changed : undefined; 84 | }; 85 | 86 | const cleanupYogaNode = (node?: YogaNode): void => { 87 | node?.unsetMeasureFunc(); 88 | node?.freeRecursive(); 89 | }; 90 | 91 | type Props = Record; 92 | 93 | type HostContext = { 94 | isInsideText: boolean; 95 | }; 96 | 97 | let currentUpdatePriority = NoEventPriority; 98 | 99 | export default createReconciler< 100 | ElementNames, 101 | Props, 102 | DOMElement, 103 | DOMElement, 104 | TextNode, 105 | DOMElement, 106 | unknown, 107 | unknown, 108 | unknown, 109 | HostContext, 110 | unknown, 111 | unknown, 112 | unknown, 113 | unknown 114 | >({ 115 | getRootHostContext: () => ({ 116 | isInsideText: false, 117 | }), 118 | prepareForCommit: () => null, 119 | preparePortalMount: () => null, 120 | clearContainer: () => false, 121 | resetAfterCommit(rootNode) { 122 | if (typeof rootNode.onComputeLayout === 'function') { 123 | rootNode.onComputeLayout(); 124 | } 125 | 126 | // Since renders are throttled at the instance level and component children 127 | // are rendered only once and then get deleted, we need an escape hatch to 128 | // trigger an immediate render to ensure children are written to output before they get erased 129 | if (rootNode.isStaticDirty) { 130 | rootNode.isStaticDirty = false; 131 | if (typeof rootNode.onImmediateRender === 'function') { 132 | rootNode.onImmediateRender(); 133 | } 134 | 135 | return; 136 | } 137 | 138 | if (typeof rootNode.onRender === 'function') { 139 | rootNode.onRender(); 140 | } 141 | }, 142 | getChildHostContext(parentHostContext, type) { 143 | const previousIsInsideText = parentHostContext.isInsideText; 144 | const isInsideText = type === 'ink-text' || type === 'ink-virtual-text'; 145 | 146 | if (previousIsInsideText === isInsideText) { 147 | return parentHostContext; 148 | } 149 | 150 | return {isInsideText}; 151 | }, 152 | shouldSetTextContent: () => false, 153 | createInstance(originalType, newProps, rootNode, hostContext) { 154 | if (hostContext.isInsideText && originalType === 'ink-box') { 155 | throw new Error(` can’t be nested inside component`); 156 | } 157 | 158 | const type = 159 | originalType === 'ink-text' && hostContext.isInsideText 160 | ? 'ink-virtual-text' 161 | : originalType; 162 | 163 | const node = createNode(type); 164 | 165 | for (const [key, value] of Object.entries(newProps)) { 166 | if (key === 'children') { 167 | continue; 168 | } 169 | 170 | if (key === 'style') { 171 | setStyle(node, value as Styles); 172 | 173 | if (node.yogaNode) { 174 | applyStyles(node.yogaNode, value as Styles); 175 | } 176 | 177 | continue; 178 | } 179 | 180 | if (key === 'internal_transform') { 181 | node.internal_transform = value as OutputTransformer; 182 | continue; 183 | } 184 | 185 | if (key === 'internal_static') { 186 | node.internal_static = true; 187 | rootNode.isStaticDirty = true; 188 | 189 | // Save reference to node to skip traversal of entire 190 | // node tree to find it 191 | rootNode.staticNode = node; 192 | continue; 193 | } 194 | 195 | setAttribute(node, key, value as DOMNodeAttribute); 196 | } 197 | 198 | return node; 199 | }, 200 | createTextInstance(text, _root, hostContext) { 201 | if (!hostContext.isInsideText) { 202 | throw new Error( 203 | `Text string "${text}" must be rendered inside component`, 204 | ); 205 | } 206 | 207 | return createTextNode(text); 208 | }, 209 | resetTextContent() {}, 210 | hideTextInstance(node) { 211 | setTextNodeValue(node, ''); 212 | }, 213 | unhideTextInstance(node, text) { 214 | setTextNodeValue(node, text); 215 | }, 216 | getPublicInstance: instance => instance, 217 | hideInstance(node) { 218 | node.yogaNode?.setDisplay(Yoga.DISPLAY_NONE); 219 | }, 220 | unhideInstance(node) { 221 | node.yogaNode?.setDisplay(Yoga.DISPLAY_FLEX); 222 | }, 223 | appendInitialChild: appendChildNode, 224 | appendChild: appendChildNode, 225 | insertBefore: insertBeforeNode, 226 | finalizeInitialChildren() { 227 | return false; 228 | }, 229 | isPrimaryRenderer: true, 230 | supportsMutation: true, 231 | supportsPersistence: false, 232 | supportsHydration: false, 233 | scheduleTimeout: setTimeout, 234 | cancelTimeout: clearTimeout, 235 | noTimeout: -1, 236 | beforeActiveInstanceBlur() {}, 237 | afterActiveInstanceBlur() {}, 238 | detachDeletedInstance() {}, 239 | getInstanceFromNode: () => null, 240 | prepareScopeUpdate() {}, 241 | getInstanceFromScope: () => null, 242 | appendChildToContainer: appendChildNode, 243 | insertInContainerBefore: insertBeforeNode, 244 | removeChildFromContainer(node, removeNode) { 245 | removeChildNode(node, removeNode); 246 | cleanupYogaNode(removeNode.yogaNode); 247 | }, 248 | commitUpdate(node, _type, oldProps, newProps, _root) { 249 | const props = diff(oldProps, newProps); 250 | 251 | const style = diff( 252 | oldProps['style'] as Styles, 253 | newProps['style'] as Styles, 254 | ); 255 | 256 | if (!props && !style) { 257 | return; 258 | } 259 | 260 | if (props) { 261 | for (const [key, value] of Object.entries(props)) { 262 | if (key === 'style') { 263 | setStyle(node, value as Styles); 264 | continue; 265 | } 266 | 267 | if (key === 'internal_transform') { 268 | node.internal_transform = value as OutputTransformer; 269 | continue; 270 | } 271 | 272 | if (key === 'internal_static') { 273 | node.internal_static = true; 274 | continue; 275 | } 276 | 277 | setAttribute(node, key, value as DOMNodeAttribute); 278 | } 279 | } 280 | 281 | if (style && node.yogaNode) { 282 | applyStyles(node.yogaNode, style); 283 | } 284 | }, 285 | commitTextUpdate(node, _oldText, newText) { 286 | setTextNodeValue(node, newText); 287 | }, 288 | removeChild(node, removeNode) { 289 | removeChildNode(node, removeNode); 290 | cleanupYogaNode(removeNode.yogaNode); 291 | }, 292 | setCurrentUpdatePriority(newPriority: number) { 293 | currentUpdatePriority = newPriority; 294 | }, 295 | getCurrentUpdatePriority: () => currentUpdatePriority, 296 | resolveUpdatePriority() { 297 | if (currentUpdatePriority !== NoEventPriority) { 298 | return currentUpdatePriority; 299 | } 300 | 301 | return DefaultEventPriority; 302 | }, 303 | maySuspendCommit() { 304 | return false; 305 | }, 306 | // eslint-disable-next-line @typescript-eslint/naming-convention 307 | NotPendingTransition: undefined, 308 | // eslint-disable-next-line @typescript-eslint/naming-convention 309 | HostTransitionContext: createContext( 310 | null, 311 | ) as unknown as ReactContext, 312 | resetFormInstance() {}, 313 | requestPostPaintCallback() {}, 314 | shouldAttemptEagerTransition() { 315 | return false; 316 | }, 317 | trackSchedulerEvent() {}, 318 | resolveEventType() { 319 | return null; 320 | }, 321 | resolveEventTimeStamp() { 322 | return -1.1; 323 | }, 324 | preloadInstance() { 325 | return true; 326 | }, 327 | startSuspendingCommit() {}, 328 | suspendInstance() {}, 329 | waitForCommitToBeReady() { 330 | return null; 331 | }, 332 | }); 333 | -------------------------------------------------------------------------------- /src/render-border.ts: -------------------------------------------------------------------------------- 1 | import cliBoxes from 'cli-boxes'; 2 | import chalk from 'chalk'; 3 | import colorize from './colorize.js'; 4 | import {type DOMNode} from './dom.js'; 5 | import type Output from './output.js'; 6 | 7 | const renderBorder = ( 8 | x: number, 9 | y: number, 10 | node: DOMNode, 11 | output: Output, 12 | ): void => { 13 | if (node.style.borderStyle) { 14 | const width = node.yogaNode!.getComputedWidth(); 15 | const height = node.yogaNode!.getComputedHeight(); 16 | const box = 17 | typeof node.style.borderStyle === 'string' 18 | ? cliBoxes[node.style.borderStyle] 19 | : node.style.borderStyle; 20 | 21 | const topBorderColor = node.style.borderTopColor ?? node.style.borderColor; 22 | const bottomBorderColor = 23 | node.style.borderBottomColor ?? node.style.borderColor; 24 | const leftBorderColor = 25 | node.style.borderLeftColor ?? node.style.borderColor; 26 | const rightBorderColor = 27 | node.style.borderRightColor ?? node.style.borderColor; 28 | 29 | const dimTopBorderColor = 30 | node.style.borderTopDimColor ?? node.style.borderDimColor; 31 | 32 | const dimBottomBorderColor = 33 | node.style.borderBottomDimColor ?? node.style.borderDimColor; 34 | 35 | const dimLeftBorderColor = 36 | node.style.borderLeftDimColor ?? node.style.borderDimColor; 37 | 38 | const dimRightBorderColor = 39 | node.style.borderRightDimColor ?? node.style.borderDimColor; 40 | 41 | const showTopBorder = node.style.borderTop !== false; 42 | const showBottomBorder = node.style.borderBottom !== false; 43 | const showLeftBorder = node.style.borderLeft !== false; 44 | const showRightBorder = node.style.borderRight !== false; 45 | 46 | const contentWidth = 47 | width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0); 48 | 49 | let topBorder = showTopBorder 50 | ? colorize( 51 | (showLeftBorder ? box.topLeft : '') + 52 | box.top.repeat(contentWidth) + 53 | (showRightBorder ? box.topRight : ''), 54 | topBorderColor, 55 | 'foreground', 56 | ) 57 | : undefined; 58 | 59 | if (showTopBorder && dimTopBorderColor) { 60 | topBorder = chalk.dim(topBorder); 61 | } 62 | 63 | let verticalBorderHeight = height; 64 | 65 | if (showTopBorder) { 66 | verticalBorderHeight -= 1; 67 | } 68 | 69 | if (showBottomBorder) { 70 | verticalBorderHeight -= 1; 71 | } 72 | 73 | let leftBorder = ( 74 | colorize(box.left, leftBorderColor, 'foreground') + '\n' 75 | ).repeat(verticalBorderHeight); 76 | 77 | if (dimLeftBorderColor) { 78 | leftBorder = chalk.dim(leftBorder); 79 | } 80 | 81 | let rightBorder = ( 82 | colorize(box.right, rightBorderColor, 'foreground') + '\n' 83 | ).repeat(verticalBorderHeight); 84 | 85 | if (dimRightBorderColor) { 86 | rightBorder = chalk.dim(rightBorder); 87 | } 88 | 89 | let bottomBorder = showBottomBorder 90 | ? colorize( 91 | (showLeftBorder ? box.bottomLeft : '') + 92 | box.bottom.repeat(contentWidth) + 93 | (showRightBorder ? box.bottomRight : ''), 94 | bottomBorderColor, 95 | 'foreground', 96 | ) 97 | : undefined; 98 | 99 | if (showBottomBorder && dimBottomBorderColor) { 100 | bottomBorder = chalk.dim(bottomBorder); 101 | } 102 | 103 | const offsetY = showTopBorder ? 1 : 0; 104 | 105 | if (topBorder) { 106 | output.write(x, y, topBorder, {transformers: []}); 107 | } 108 | 109 | if (showLeftBorder) { 110 | output.write(x, y + offsetY, leftBorder, {transformers: []}); 111 | } 112 | 113 | if (showRightBorder) { 114 | output.write(x + width - 1, y + offsetY, rightBorder, { 115 | transformers: [], 116 | }); 117 | } 118 | 119 | if (bottomBorder) { 120 | output.write(x, y + height - 1, bottomBorder, {transformers: []}); 121 | } 122 | } 123 | }; 124 | 125 | export default renderBorder; 126 | -------------------------------------------------------------------------------- /src/render-node-to-output.ts: -------------------------------------------------------------------------------- 1 | import widestLine from 'widest-line'; 2 | import indentString from 'indent-string'; 3 | import Yoga from 'yoga-layout'; 4 | import wrapText from './wrap-text.js'; 5 | import getMaxWidth from './get-max-width.js'; 6 | import squashTextNodes from './squash-text-nodes.js'; 7 | import renderBorder from './render-border.js'; 8 | import {type DOMElement} from './dom.js'; 9 | import type Output from './output.js'; 10 | 11 | // If parent container is ``, text nodes will be treated as separate nodes in 12 | // the tree and will have their own coordinates in the layout. 13 | // To ensure text nodes are aligned correctly, take X and Y of the first text node 14 | // and use it as offset for the rest of the nodes 15 | // Only first node is taken into account, because other text nodes can't have margin or padding, 16 | // so their coordinates will be relative to the first node anyway 17 | const applyPaddingToText = (node: DOMElement, text: string): string => { 18 | const yogaNode = node.childNodes[0]?.yogaNode; 19 | 20 | if (yogaNode) { 21 | const offsetX = yogaNode.getComputedLeft(); 22 | const offsetY = yogaNode.getComputedTop(); 23 | text = '\n'.repeat(offsetY) + indentString(text, offsetX); 24 | } 25 | 26 | return text; 27 | }; 28 | 29 | export type OutputTransformer = (s: string, index: number) => string; 30 | 31 | // After nodes are laid out, render each to output object, which later gets rendered to terminal 32 | const renderNodeToOutput = ( 33 | node: DOMElement, 34 | output: Output, 35 | options: { 36 | offsetX?: number; 37 | offsetY?: number; 38 | transformers?: OutputTransformer[]; 39 | skipStaticElements: boolean; 40 | }, 41 | ) => { 42 | const { 43 | offsetX = 0, 44 | offsetY = 0, 45 | transformers = [], 46 | skipStaticElements, 47 | } = options; 48 | 49 | if (skipStaticElements && node.internal_static) { 50 | return; 51 | } 52 | 53 | const {yogaNode} = node; 54 | 55 | if (yogaNode) { 56 | if (yogaNode.getDisplay() === Yoga.DISPLAY_NONE) { 57 | return; 58 | } 59 | 60 | // Left and top positions in Yoga are relative to their parent node 61 | const x = offsetX + yogaNode.getComputedLeft(); 62 | const y = offsetY + yogaNode.getComputedTop(); 63 | 64 | // Transformers are functions that transform final text output of each component 65 | // See Output class for logic that applies transformers 66 | let newTransformers = transformers; 67 | 68 | if (typeof node.internal_transform === 'function') { 69 | newTransformers = [node.internal_transform, ...transformers]; 70 | } 71 | 72 | if (node.nodeName === 'ink-text') { 73 | let text = squashTextNodes(node); 74 | 75 | if (text.length > 0) { 76 | const currentWidth = widestLine(text); 77 | const maxWidth = getMaxWidth(yogaNode); 78 | 79 | if (currentWidth > maxWidth) { 80 | const textWrap = node.style.textWrap ?? 'wrap'; 81 | text = wrapText(text, maxWidth, textWrap); 82 | } 83 | 84 | text = applyPaddingToText(node, text); 85 | 86 | output.write(x, y, text, {transformers: newTransformers}); 87 | } 88 | 89 | return; 90 | } 91 | 92 | let clipped = false; 93 | 94 | if (node.nodeName === 'ink-box') { 95 | renderBorder(x, y, node, output); 96 | 97 | const clipHorizontally = 98 | node.style.overflowX === 'hidden' || node.style.overflow === 'hidden'; 99 | const clipVertically = 100 | node.style.overflowY === 'hidden' || node.style.overflow === 'hidden'; 101 | 102 | if (clipHorizontally || clipVertically) { 103 | const x1 = clipHorizontally 104 | ? x + yogaNode.getComputedBorder(Yoga.EDGE_LEFT) 105 | : undefined; 106 | 107 | const x2 = clipHorizontally 108 | ? x + 109 | yogaNode.getComputedWidth() - 110 | yogaNode.getComputedBorder(Yoga.EDGE_RIGHT) 111 | : undefined; 112 | 113 | const y1 = clipVertically 114 | ? y + yogaNode.getComputedBorder(Yoga.EDGE_TOP) 115 | : undefined; 116 | 117 | const y2 = clipVertically 118 | ? y + 119 | yogaNode.getComputedHeight() - 120 | yogaNode.getComputedBorder(Yoga.EDGE_BOTTOM) 121 | : undefined; 122 | 123 | output.clip({x1, x2, y1, y2}); 124 | clipped = true; 125 | } 126 | } 127 | 128 | if (node.nodeName === 'ink-root' || node.nodeName === 'ink-box') { 129 | for (const childNode of node.childNodes) { 130 | renderNodeToOutput(childNode as DOMElement, output, { 131 | offsetX: x, 132 | offsetY: y, 133 | transformers: newTransformers, 134 | skipStaticElements, 135 | }); 136 | } 137 | 138 | if (clipped) { 139 | output.unclip(); 140 | } 141 | } 142 | } 143 | }; 144 | 145 | export default renderNodeToOutput; 146 | -------------------------------------------------------------------------------- /src/render.ts: -------------------------------------------------------------------------------- 1 | import {Stream} from 'node:stream'; 2 | import process from 'node:process'; 3 | import type {ReactNode} from 'react'; 4 | import Ink, {type Options as InkOptions} from './ink.js'; 5 | import instances from './instances.js'; 6 | 7 | export type RenderOptions = { 8 | /** 9 | * Output stream where app will be rendered. 10 | * 11 | * @default process.stdout 12 | */ 13 | stdout?: NodeJS.WriteStream; 14 | /** 15 | * Input stream where app will listen for input. 16 | * 17 | * @default process.stdin 18 | */ 19 | stdin?: NodeJS.ReadStream; 20 | /** 21 | * Error stream. 22 | * @default process.stderr 23 | */ 24 | stderr?: NodeJS.WriteStream; 25 | /** 26 | * If true, each update will be rendered as a separate output, without replacing the previous one. 27 | * 28 | * @default false 29 | */ 30 | debug?: boolean; 31 | /** 32 | * Configure whether Ink should listen to 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 process is expected to handle it manually. 33 | * 34 | * @default true 35 | */ 36 | exitOnCtrlC?: boolean; 37 | 38 | /** 39 | * Patch console methods to ensure console output doesn't mix with Ink output. 40 | * 41 | * @default true 42 | */ 43 | patchConsole?: boolean; 44 | }; 45 | 46 | export type Instance = { 47 | /** 48 | * Replace previous root node with a new one or update props of the current root node. 49 | */ 50 | rerender: Ink['render']; 51 | /** 52 | * Manually unmount the whole Ink app. 53 | */ 54 | unmount: Ink['unmount']; 55 | /** 56 | * Returns a promise, which resolves when app is unmounted. 57 | */ 58 | waitUntilExit: Ink['waitUntilExit']; 59 | cleanup: () => void; 60 | 61 | /** 62 | * Clear output. 63 | */ 64 | clear: () => void; 65 | }; 66 | 67 | /** 68 | * Mount a component and render the output. 69 | */ 70 | const render = ( 71 | node: ReactNode, 72 | options?: NodeJS.WriteStream | RenderOptions, 73 | ): Instance => { 74 | const inkOptions: InkOptions = { 75 | stdout: process.stdout, 76 | stdin: process.stdin, 77 | stderr: process.stderr, 78 | debug: false, 79 | exitOnCtrlC: true, 80 | patchConsole: true, 81 | ...getOptions(options), 82 | }; 83 | 84 | const instance: Ink = getInstance( 85 | inkOptions.stdout, 86 | () => new Ink(inkOptions), 87 | ); 88 | 89 | instance.render(node); 90 | 91 | return { 92 | rerender: instance.render, 93 | unmount() { 94 | instance.unmount(); 95 | }, 96 | waitUntilExit: instance.waitUntilExit, 97 | cleanup: () => instances.delete(inkOptions.stdout), 98 | clear: instance.clear, 99 | }; 100 | }; 101 | 102 | export default render; 103 | 104 | const getOptions = ( 105 | stdout: NodeJS.WriteStream | RenderOptions | undefined = {}, 106 | ): RenderOptions => { 107 | if (stdout instanceof Stream) { 108 | return { 109 | stdout, 110 | stdin: process.stdin, 111 | }; 112 | } 113 | 114 | return stdout; 115 | }; 116 | 117 | const getInstance = ( 118 | stdout: NodeJS.WriteStream, 119 | createInstance: () => Ink, 120 | ): Ink => { 121 | let instance = instances.get(stdout); 122 | 123 | if (!instance) { 124 | instance = createInstance(); 125 | instances.set(stdout, instance); 126 | } 127 | 128 | return instance; 129 | }; 130 | -------------------------------------------------------------------------------- /src/renderer.ts: -------------------------------------------------------------------------------- 1 | import renderNodeToOutput from './render-node-to-output.js'; 2 | import Output from './output.js'; 3 | import {type DOMElement} from './dom.js'; 4 | 5 | type Result = { 6 | output: string; 7 | outputHeight: number; 8 | staticOutput: string; 9 | }; 10 | 11 | const renderer = (node: DOMElement): Result => { 12 | if (node.yogaNode) { 13 | const output = new Output({ 14 | width: node.yogaNode.getComputedWidth(), 15 | height: node.yogaNode.getComputedHeight(), 16 | }); 17 | 18 | renderNodeToOutput(node, output, {skipStaticElements: true}); 19 | 20 | let staticOutput; 21 | 22 | if (node.staticNode?.yogaNode) { 23 | staticOutput = new Output({ 24 | width: node.staticNode.yogaNode.getComputedWidth(), 25 | height: node.staticNode.yogaNode.getComputedHeight(), 26 | }); 27 | 28 | renderNodeToOutput(node.staticNode, staticOutput, { 29 | skipStaticElements: false, 30 | }); 31 | } 32 | 33 | const {output: generatedOutput, height: outputHeight} = output.get(); 34 | 35 | return { 36 | output: generatedOutput, 37 | outputHeight, 38 | // Newline at the end is needed, because static output doesn't have one, so 39 | // interactive output will override last line of static output 40 | staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '', 41 | }; 42 | } 43 | 44 | return { 45 | output: '', 46 | outputHeight: 0, 47 | staticOutput: '', 48 | }; 49 | }; 50 | 51 | export default renderer; 52 | -------------------------------------------------------------------------------- /src/squash-text-nodes.ts: -------------------------------------------------------------------------------- 1 | import {type DOMElement} from './dom.js'; 2 | 3 | // Squashing text nodes allows to combine multiple text nodes into one and write 4 | // to `Output` instance only once. For example, hello{' '}world 5 | // is actually 3 text nodes, which would result 3 writes to `Output`. 6 | // 7 | // Also, this is necessary for libraries like ink-link (https://github.com/sindresorhus/ink-link), 8 | // which need to wrap all children at once, instead of wrapping 3 text nodes separately. 9 | const squashTextNodes = (node: DOMElement): string => { 10 | let text = ''; 11 | 12 | for (let index = 0; index < node.childNodes.length; index++) { 13 | const childNode = node.childNodes[index]; 14 | 15 | if (childNode === undefined) { 16 | continue; 17 | } 18 | 19 | let nodeText = ''; 20 | 21 | if (childNode.nodeName === '#text') { 22 | nodeText = childNode.nodeValue; 23 | } else { 24 | if ( 25 | childNode.nodeName === 'ink-text' || 26 | childNode.nodeName === 'ink-virtual-text' 27 | ) { 28 | nodeText = squashTextNodes(childNode); 29 | } 30 | 31 | // Since these text nodes are being concatenated, `Output` instance won't be able to 32 | // apply children transform, so we have to do it manually here for each text node 33 | if ( 34 | nodeText.length > 0 && 35 | typeof childNode.internal_transform === 'function' 36 | ) { 37 | nodeText = childNode.internal_transform(nodeText, index); 38 | } 39 | } 40 | 41 | text += nodeText; 42 | } 43 | 44 | return text; 45 | }; 46 | 47 | export default squashTextNodes; 48 | -------------------------------------------------------------------------------- /src/wrap-text.ts: -------------------------------------------------------------------------------- 1 | import wrapAnsi from 'wrap-ansi'; 2 | import cliTruncate from 'cli-truncate'; 3 | import {type Styles} from './styles.js'; 4 | 5 | const cache: Record = {}; 6 | 7 | const wrapText = ( 8 | text: string, 9 | maxWidth: number, 10 | wrapType: Styles['textWrap'], 11 | ): string => { 12 | const cacheKey = text + String(maxWidth) + String(wrapType); 13 | const cachedText = cache[cacheKey]; 14 | 15 | if (cachedText) { 16 | return cachedText; 17 | } 18 | 19 | let wrappedText = text; 20 | 21 | if (wrapType === 'wrap') { 22 | wrappedText = wrapAnsi(text, maxWidth, { 23 | trim: false, 24 | hard: true, 25 | }); 26 | } 27 | 28 | if (wrapType!.startsWith('truncate')) { 29 | let position: 'end' | 'middle' | 'start' = 'end'; 30 | 31 | if (wrapType === 'truncate-middle') { 32 | position = 'middle'; 33 | } 34 | 35 | if (wrapType === 'truncate-start') { 36 | position = 'start'; 37 | } 38 | 39 | wrappedText = cliTruncate(text, maxWidth, {position}); 40 | } 41 | 42 | cache[cacheKey] = wrappedText; 43 | 44 | return wrappedText; 45 | }; 46 | 47 | export default wrapText; 48 | -------------------------------------------------------------------------------- /test/display.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('display flex', t => { 7 | const output = renderToString( 8 | 9 | X 10 | , 11 | ); 12 | t.is(output, 'X'); 13 | }); 14 | 15 | test('display none', t => { 16 | const output = renderToString( 17 | 18 | 19 | Kitty! 20 | 21 | Doggo 22 | , 23 | ); 24 | 25 | t.is(output, 'Doggo'); 26 | }); 27 | -------------------------------------------------------------------------------- /test/errors.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import patchConsole from 'patch-console'; 4 | import stripAnsi from 'strip-ansi'; 5 | import {render} from '../src/index.js'; 6 | import createStdout from './helpers/create-stdout.js'; 7 | 8 | let restore = () => {}; 9 | 10 | test.before(() => { 11 | restore = patchConsole(() => {}); 12 | }); 13 | 14 | test.after(() => { 15 | restore(); 16 | }); 17 | 18 | test('catch and display error', t => { 19 | const stdout = createStdout(); 20 | 21 | const Test = () => { 22 | throw new Error('Oh no'); 23 | }; 24 | 25 | render(, {stdout}); 26 | 27 | t.deepEqual( 28 | stripAnsi((stdout.write as any).lastCall.args[0] as string) 29 | .split('\n') 30 | .slice(0, 14), 31 | [ 32 | '', 33 | ' ERROR Oh no', 34 | '', 35 | ' test/errors.tsx:22:9', 36 | '', 37 | ' 19: const stdout = createStdout();', 38 | ' 20:', 39 | ' 21: const Test = () => {', 40 | " 22: throw new Error('Oh no');", 41 | ' 23: };', 42 | ' 24:', 43 | ' 25: render(, {stdout});', 44 | '', 45 | ' - Test (test/errors.tsx:22:9)', 46 | ], 47 | ); 48 | }); 49 | -------------------------------------------------------------------------------- /test/exit.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import * as path from 'node:path'; 3 | import url from 'node:url'; 4 | import {createRequire} from 'node:module'; 5 | import test from 'ava'; 6 | import {run} from './helpers/run.js'; 7 | 8 | const require = createRequire(import.meta.url); 9 | 10 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 11 | const {spawn} = require('node-pty') as typeof import('node-pty'); 12 | 13 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); 14 | 15 | test.serial('exit normally without unmount() or exit()', async t => { 16 | const output = await run('exit-normally'); 17 | t.true(output.includes('exited')); 18 | }); 19 | 20 | test.serial('exit on unmount()', async t => { 21 | const output = await run('exit-on-unmount'); 22 | t.true(output.includes('exited')); 23 | }); 24 | 25 | test.serial('exit when app finishes execution', async t => { 26 | const ps = run('exit-on-finish'); 27 | await t.notThrowsAsync(ps); 28 | }); 29 | 30 | test.serial('exit on exit()', async t => { 31 | const output = await run('exit-on-exit'); 32 | t.true(output.includes('exited')); 33 | }); 34 | 35 | test.serial('exit on exit() with error', async t => { 36 | const output = await run('exit-on-exit-with-error'); 37 | t.true(output.includes('errored')); 38 | }); 39 | 40 | test.serial('exit on exit() with raw mode', async t => { 41 | const output = await run('exit-raw-on-exit'); 42 | t.true(output.includes('exited')); 43 | }); 44 | 45 | test.serial('exit on exit() with raw mode with error', async t => { 46 | const output = await run('exit-raw-on-exit-with-error'); 47 | t.true(output.includes('errored')); 48 | }); 49 | 50 | test.serial('exit on unmount() with raw mode', async t => { 51 | const output = await run('exit-raw-on-unmount'); 52 | t.true(output.includes('exited')); 53 | }); 54 | 55 | test.serial('exit with thrown error', async t => { 56 | const output = await run('exit-with-thrown-error'); 57 | t.true(output.includes('errored')); 58 | }); 59 | 60 | test.serial('don’t exit while raw mode is active', async t => { 61 | await new Promise((resolve, _reject) => { 62 | const env: Record = { 63 | ...process.env, 64 | // eslint-disable-next-line @typescript-eslint/naming-convention 65 | NODE_NO_WARNINGS: '1', 66 | }; 67 | 68 | const term = spawn( 69 | 'node', 70 | [ 71 | '--loader=ts-node/esm', 72 | path.join(__dirname, './fixtures/exit-double-raw-mode.tsx'), 73 | ], 74 | { 75 | name: 'xterm-color', 76 | cols: 100, 77 | cwd: __dirname, 78 | env, 79 | }, 80 | ); 81 | 82 | let output = ''; 83 | 84 | term.onData(data => { 85 | if (data === 's') { 86 | setTimeout(() => { 87 | t.false(isExited); 88 | term.write('q'); 89 | }, 2000); 90 | 91 | setTimeout(() => { 92 | term.kill(); 93 | t.fail(); 94 | resolve(); 95 | }, 5000); 96 | } else { 97 | output += data; 98 | } 99 | }); 100 | 101 | let isExited = false; 102 | 103 | term.onExit(({exitCode}) => { 104 | isExited = true; 105 | 106 | if (exitCode === 0) { 107 | t.true(output.includes('exited')); 108 | t.pass(); 109 | resolve(); 110 | return; 111 | } 112 | 113 | t.fail(); 114 | resolve(); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/fixtures/ci.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Static, Text} from '../../src/index.js'; 3 | 4 | type TestState = { 5 | counter: number; 6 | items: string[]; 7 | }; 8 | 9 | class Test extends React.Component, TestState> { 10 | timer?: NodeJS.Timeout; 11 | 12 | override state: TestState = { 13 | items: [], 14 | counter: 0, 15 | }; 16 | 17 | override render() { 18 | return ( 19 | <> 20 | 21 | {item => {item}} 22 | 23 | 24 | Counter: {this.state.counter} 25 | 26 | ); 27 | } 28 | 29 | override componentDidMount() { 30 | const onTimeout = () => { 31 | if (this.state.counter > 4) { 32 | return; 33 | } 34 | 35 | this.setState(prevState => ({ 36 | counter: prevState.counter + 1, 37 | items: [...prevState.items, `#${prevState.counter + 1}`], 38 | })); 39 | 40 | this.timer = setTimeout(onTimeout, 100); 41 | }; 42 | 43 | this.timer = setTimeout(onTimeout, 100); 44 | } 45 | 46 | override componentWillUnmount() { 47 | clearTimeout(this.timer); 48 | } 49 | } 50 | 51 | render(); 52 | -------------------------------------------------------------------------------- /test/fixtures/clear.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Box, Text, render} from '../../src/index.js'; 3 | 4 | function Clear() { 5 | return ( 6 | 7 | A 8 | B 9 | C 10 | 11 | ); 12 | } 13 | 14 | const {clear} = render(); 15 | clear(); 16 | -------------------------------------------------------------------------------- /test/fixtures/console.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | import {Text, render} from '../../src/index.js'; 3 | 4 | function App() { 5 | useEffect(() => { 6 | const timer = setTimeout(() => {}, 1000); 7 | 8 | return () => { 9 | clearTimeout(timer); 10 | }; 11 | }, []); 12 | 13 | return Hello World; 14 | } 15 | 16 | const {unmount} = render(); 17 | console.log('First log'); 18 | unmount(); 19 | console.log('Second log'); 20 | -------------------------------------------------------------------------------- /test/fixtures/erase-with-state-change.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import React, {useEffect, useState} from 'react'; 3 | import {Box, Text, render} from '../../src/index.js'; 4 | 5 | function Erase() { 6 | const [show, setShow] = useState(true); 7 | 8 | useEffect(() => { 9 | const timer = setTimeout(() => { 10 | setShow(false); 11 | }); 12 | 13 | return () => { 14 | clearTimeout(timer); 15 | }; 16 | }, []); 17 | 18 | return ( 19 | 20 | {show && ( 21 | <> 22 | A 23 | B 24 | C 25 | 26 | )} 27 | 28 | ); 29 | } 30 | 31 | process.stdout.rows = Number(process.argv[2]); 32 | render(); 33 | -------------------------------------------------------------------------------- /test/fixtures/erase-with-static.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import React from 'react'; 3 | import {Static, Box, Text, render} from '../../src/index.js'; 4 | 5 | function EraseWithStatic() { 6 | return ( 7 | <> 8 | 9 | {item => {item}} 10 | 11 | 12 | 13 | D 14 | E 15 | F 16 | 17 | 18 | ); 19 | } 20 | 21 | process.stdout.rows = Number(process.argv[3]); 22 | render(); 23 | -------------------------------------------------------------------------------- /test/fixtures/erase.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import React from 'react'; 3 | import {Box, Text, render} from '../../src/index.js'; 4 | 5 | function Erase() { 6 | return ( 7 | 8 | A 9 | B 10 | C 11 | 12 | ); 13 | } 14 | 15 | process.stdout.rows = Number(process.argv[2]); 16 | render(); 17 | -------------------------------------------------------------------------------- /test/fixtures/exit-double-raw-mode.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import React from 'react'; 3 | import {Text, render, useStdin} from '../../src/index.js'; 4 | 5 | class ExitDoubleRawMode extends React.Component<{ 6 | setRawMode: (value: boolean) => void; 7 | }> { 8 | override render() { 9 | return Hello World; 10 | } 11 | 12 | override componentDidMount() { 13 | const {setRawMode} = this.props; 14 | 15 | setRawMode(true); 16 | 17 | setTimeout(() => { 18 | setRawMode(false); 19 | setRawMode(true); 20 | 21 | // Start the test 22 | process.stdout.write('s'); 23 | }, 500); 24 | } 25 | } 26 | 27 | function Test() { 28 | const {setRawMode} = useStdin(); 29 | 30 | return ; 31 | } 32 | 33 | const {unmount, waitUntilExit} = render(); 34 | 35 | process.stdin.on('data', data => { 36 | if (String(data) === 'q') { 37 | unmount(); 38 | } 39 | }); 40 | 41 | await waitUntilExit(); 42 | console.log('exited'); 43 | -------------------------------------------------------------------------------- /test/fixtures/exit-normally.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Text, render} from '../../src/index.js'; 3 | 4 | const {waitUntilExit} = render(Hello World); 5 | 6 | await waitUntilExit(); 7 | console.log('exited'); 8 | -------------------------------------------------------------------------------- /test/fixtures/exit-on-exit-with-error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text, useApp} from '../../src/index.js'; 3 | 4 | class Exit extends React.Component< 5 | {onExit: (error: Error) => void}, 6 | {counter: number} 7 | > { 8 | timer?: NodeJS.Timeout; 9 | 10 | override state = { 11 | counter: 0, 12 | }; 13 | 14 | override render() { 15 | return Counter: {this.state.counter}; 16 | } 17 | 18 | override componentDidMount() { 19 | setTimeout(() => { 20 | this.props.onExit(new Error('errored')); 21 | }, 500); 22 | 23 | this.timer = setInterval(() => { 24 | this.setState(prevState => ({ 25 | counter: prevState.counter + 1, 26 | })); 27 | }, 100); 28 | } 29 | 30 | override componentWillUnmount() { 31 | clearInterval(this.timer); 32 | } 33 | } 34 | 35 | function Test() { 36 | const {exit} = useApp(); 37 | return ; 38 | } 39 | 40 | const app = render(); 41 | 42 | try { 43 | await app.waitUntilExit(); 44 | } catch (error: unknown) { 45 | console.log((error as any).message); 46 | } 47 | -------------------------------------------------------------------------------- /test/fixtures/exit-on-exit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text, useApp} from '../../src/index.js'; 3 | 4 | class Exit extends React.Component< 5 | {onExit: (error: Error) => void}, 6 | {counter: number} 7 | > { 8 | timer?: NodeJS.Timeout; 9 | 10 | override state = { 11 | counter: 0, 12 | }; 13 | 14 | override render() { 15 | return Counter: {this.state.counter}; 16 | } 17 | 18 | override componentDidMount() { 19 | setTimeout(this.props.onExit, 500); 20 | 21 | this.timer = setInterval(() => { 22 | this.setState(prevState => ({ 23 | counter: prevState.counter + 1, 24 | })); 25 | }, 100); 26 | } 27 | 28 | override componentWillUnmount() { 29 | clearInterval(this.timer); 30 | } 31 | } 32 | 33 | function Test() { 34 | const {exit} = useApp(); 35 | return ; 36 | } 37 | 38 | const app = render(); 39 | 40 | await app.waitUntilExit(); 41 | console.log('exited'); 42 | -------------------------------------------------------------------------------- /test/fixtures/exit-on-finish.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text} from '../../src/index.js'; 3 | 4 | class Test extends React.Component, {counter: number}> { 5 | timer?: NodeJS.Timeout; 6 | 7 | override state = { 8 | counter: 0, 9 | }; 10 | 11 | override render() { 12 | return Counter: {this.state.counter}; 13 | } 14 | 15 | override componentDidMount() { 16 | const onTimeout = () => { 17 | if (this.state.counter > 4) { 18 | return; 19 | } 20 | 21 | this.setState(prevState => ({ 22 | counter: prevState.counter + 1, 23 | })); 24 | 25 | this.timer = setTimeout(onTimeout, 100); 26 | }; 27 | 28 | this.timer = setTimeout(onTimeout, 100); 29 | } 30 | 31 | override componentWillUnmount() { 32 | clearTimeout(this.timer); 33 | } 34 | } 35 | 36 | render(); 37 | -------------------------------------------------------------------------------- /test/fixtures/exit-on-unmount.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text} from '../../src/index.js'; 3 | 4 | class Test extends React.Component, {counter: number}> { 5 | timer?: NodeJS.Timeout; 6 | 7 | override state = { 8 | counter: 0, 9 | }; 10 | 11 | override render() { 12 | return Counter: {this.state.counter}; 13 | } 14 | 15 | override componentDidMount() { 16 | this.timer = setInterval(() => { 17 | this.setState(prevState => ({ 18 | counter: prevState.counter + 1, 19 | })); 20 | }, 100); 21 | } 22 | 23 | override componentWillUnmount() { 24 | clearInterval(this.timer); 25 | } 26 | } 27 | 28 | const app = render(); 29 | 30 | setTimeout(() => { 31 | app.unmount(); 32 | }, 500); 33 | 34 | await app.waitUntilExit(); 35 | console.log('exited'); 36 | -------------------------------------------------------------------------------- /test/fixtures/exit-raw-on-exit-with-error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text, useApp, useStdin} from '../../src/index.js'; 3 | 4 | class Exit extends React.Component<{ 5 | onSetRawMode: (value: boolean) => void; 6 | onExit: (error: Error) => void; 7 | }> { 8 | override render() { 9 | return Hello World; 10 | } 11 | 12 | override componentDidMount() { 13 | this.props.onSetRawMode(true); 14 | 15 | setTimeout(() => { 16 | this.props.onExit(new Error('errored')); 17 | }, 500); 18 | } 19 | } 20 | 21 | function Test() { 22 | const {exit} = useApp(); 23 | const {setRawMode} = useStdin(); 24 | 25 | return ; 26 | } 27 | 28 | const app = render(); 29 | 30 | try { 31 | await app.waitUntilExit(); 32 | } catch (error: unknown) { 33 | console.log((error as any).message); 34 | } 35 | -------------------------------------------------------------------------------- /test/fixtures/exit-raw-on-exit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text, useApp, useStdin} from '../../src/index.js'; 3 | 4 | class Exit extends React.Component<{ 5 | onSetRawMode: (value: boolean) => void; 6 | onExit: (error: Error) => void; 7 | }> { 8 | override render() { 9 | return Hello World; 10 | } 11 | 12 | override componentDidMount() { 13 | this.props.onSetRawMode(true); 14 | setTimeout(this.props.onExit, 500); 15 | } 16 | } 17 | 18 | function Test() { 19 | const {exit} = useApp(); 20 | const {setRawMode} = useStdin(); 21 | 22 | return ; 23 | } 24 | 25 | const app = render(); 26 | 27 | await app.waitUntilExit(); 28 | console.log('exited'); 29 | -------------------------------------------------------------------------------- /test/fixtures/exit-raw-on-unmount.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, Text, useStdin} from '../../src/index.js'; 3 | 4 | class Exit extends React.Component<{ 5 | onSetRawMode: (value: boolean) => void; 6 | }> { 7 | override render() { 8 | return Hello World; 9 | } 10 | 11 | override componentDidMount() { 12 | this.props.onSetRawMode(true); 13 | } 14 | } 15 | 16 | function Test() { 17 | const {setRawMode} = useStdin(); 18 | return ; 19 | } 20 | 21 | const app = render(); 22 | 23 | setTimeout(() => { 24 | app.unmount(); 25 | }, 500); 26 | 27 | await app.waitUntilExit(); 28 | console.log('exited'); 29 | -------------------------------------------------------------------------------- /test/fixtures/exit-with-thrown-error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render} from '../../src/index.js'; 3 | 4 | const Test = () => { 5 | throw new Error('errored'); 6 | }; 7 | 8 | const app = render(); 9 | 10 | try { 11 | await app.waitUntilExit(); 12 | } catch (error: unknown) { 13 | console.log((error as any).message); 14 | } 15 | -------------------------------------------------------------------------------- /test/fixtures/use-input-ctrl-c.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render, useInput, useApp} from '../../src/index.js'; 3 | 4 | function UserInput() { 5 | const {exit} = useApp(); 6 | 7 | useInput((input, key) => { 8 | if (input === 'c' && key.ctrl) { 9 | exit(); 10 | return; 11 | } 12 | 13 | throw new Error('Crash'); 14 | }); 15 | 16 | return null; 17 | } 18 | 19 | const app = render(, {exitOnCtrlC: false}); 20 | 21 | await app.waitUntilExit(); 22 | console.log('exited'); 23 | -------------------------------------------------------------------------------- /test/fixtures/use-input-multiple.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, useCallback, useEffect} from 'react'; 2 | import {render, useInput, useApp, Text} from '../../src/index.js'; 3 | 4 | function App() { 5 | const {exit} = useApp(); 6 | const [input, setInput] = useState(''); 7 | 8 | const handleInput = useCallback((input: string) => { 9 | setInput((previousInput: string) => previousInput + input); 10 | }, []); 11 | 12 | useInput(handleInput); 13 | useInput(handleInput, {isActive: false}); 14 | 15 | useEffect(() => { 16 | setTimeout(exit, 1000); 17 | }, []); 18 | 19 | return {input}; 20 | } 21 | 22 | const app = render(); 23 | 24 | await app.waitUntilExit(); 25 | console.log('exited'); 26 | -------------------------------------------------------------------------------- /test/fixtures/use-input.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import React from 'react'; 3 | import {render, useInput, useApp} from '../../src/index.js'; 4 | 5 | function UserInput({test}: {readonly test: string | undefined}) { 6 | const {exit} = useApp(); 7 | 8 | useInput((input, key) => { 9 | if (test === 'lowercase' && input === 'q') { 10 | exit(); 11 | return; 12 | } 13 | 14 | if (test === 'uppercase' && input === 'Q' && key.shift) { 15 | exit(); 16 | return; 17 | } 18 | 19 | if (test === 'uppercase' && input === '\r' && !key.shift) { 20 | exit(); 21 | return; 22 | } 23 | 24 | if (test === 'pastedCarriageReturn' && input === '\rtest') { 25 | exit(); 26 | return; 27 | } 28 | 29 | if (test === 'pastedTab' && input === '\ttest') { 30 | exit(); 31 | return; 32 | } 33 | 34 | if (test === 'escape' && key.escape) { 35 | exit(); 36 | return; 37 | } 38 | 39 | if (test === 'ctrl' && input === 'f' && key.ctrl) { 40 | exit(); 41 | return; 42 | } 43 | 44 | if (test === 'meta' && input === 'm' && key.meta) { 45 | exit(); 46 | return; 47 | } 48 | 49 | if (test === 'upArrow' && key.upArrow && !key.meta) { 50 | exit(); 51 | return; 52 | } 53 | 54 | if (test === 'downArrow' && key.downArrow && !key.meta) { 55 | exit(); 56 | return; 57 | } 58 | 59 | if (test === 'leftArrow' && key.leftArrow && !key.meta) { 60 | exit(); 61 | return; 62 | } 63 | 64 | if (test === 'rightArrow' && key.rightArrow && !key.meta) { 65 | exit(); 66 | return; 67 | } 68 | 69 | if (test === 'upArrowMeta' && key.upArrow && key.meta) { 70 | exit(); 71 | return; 72 | } 73 | 74 | if (test === 'downArrowMeta' && key.downArrow && key.meta) { 75 | exit(); 76 | return; 77 | } 78 | 79 | if (test === 'leftArrowMeta' && key.leftArrow && key.meta) { 80 | exit(); 81 | return; 82 | } 83 | 84 | if (test === 'rightArrowMeta' && key.rightArrow && key.meta) { 85 | exit(); 86 | return; 87 | } 88 | 89 | if (test === 'upArrowCtrl' && key.upArrow && key.ctrl) { 90 | exit(); 91 | return; 92 | } 93 | 94 | if (test === 'downArrowCtrl' && key.downArrow && key.ctrl) { 95 | exit(); 96 | return; 97 | } 98 | 99 | if (test === 'leftArrowCtrl' && key.leftArrow && key.ctrl) { 100 | exit(); 101 | return; 102 | } 103 | 104 | if (test === 'rightArrowCtrl' && key.rightArrow && key.ctrl) { 105 | exit(); 106 | return; 107 | } 108 | 109 | if (test === 'pageDown' && key.pageDown && !key.meta) { 110 | exit(); 111 | return; 112 | } 113 | 114 | if (test === 'pageUp' && key.pageUp && !key.meta) { 115 | exit(); 116 | return; 117 | } 118 | 119 | if (test === 'tab' && input === '' && key.tab && !key.ctrl) { 120 | exit(); 121 | return; 122 | } 123 | 124 | if (test === 'shiftTab' && input === '' && key.tab && key.shift) { 125 | exit(); 126 | return; 127 | } 128 | 129 | if (test === 'backspace' && input === '' && key.backspace) { 130 | exit(); 131 | return; 132 | } 133 | 134 | if (test === 'delete' && input === '' && key.delete) { 135 | exit(); 136 | return; 137 | } 138 | 139 | if (test === 'remove' && input === '' && key.delete) { 140 | exit(); 141 | return; 142 | } 143 | 144 | throw new Error('Crash'); 145 | }); 146 | 147 | return null; 148 | } 149 | 150 | const app = render(); 151 | 152 | await app.waitUntilExit(); 153 | console.log('exited'); 154 | -------------------------------------------------------------------------------- /test/fixtures/use-stdout.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | import {render, useStdout, Text} from '../../src/index.js'; 3 | 4 | function WriteToStdout() { 5 | const {write} = useStdout(); 6 | 7 | useEffect(() => { 8 | write('Hello from Ink to stdout\n'); 9 | }, []); 10 | 11 | return Hello World; 12 | } 13 | 14 | const app = render(); 15 | 16 | await app.waitUntilExit(); 17 | console.log('exited'); 18 | -------------------------------------------------------------------------------- /test/flex-align-items.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('row - align text to center', t => { 7 | const output = renderToString( 8 | 9 | Test 10 | , 11 | ); 12 | 13 | t.is(output, '\nTest\n'); 14 | }); 15 | 16 | test('row - align multiple text nodes to center', t => { 17 | const output = renderToString( 18 | 19 | A 20 | B 21 | , 22 | ); 23 | 24 | t.is(output, '\nAB\n'); 25 | }); 26 | 27 | test('row - align text to bottom', t => { 28 | const output = renderToString( 29 | 30 | Test 31 | , 32 | ); 33 | 34 | t.is(output, '\n\nTest'); 35 | }); 36 | 37 | test('row - align multiple text nodes to bottom', t => { 38 | const output = renderToString( 39 | 40 | A 41 | B 42 | , 43 | ); 44 | 45 | t.is(output, '\n\nAB'); 46 | }); 47 | 48 | test('column - align text to center', t => { 49 | const output = renderToString( 50 | 51 | Test 52 | , 53 | ); 54 | 55 | t.is(output, ' Test'); 56 | }); 57 | 58 | test('column - align text to right', t => { 59 | const output = renderToString( 60 | 61 | Test 62 | , 63 | ); 64 | 65 | t.is(output, ' Test'); 66 | }); 67 | -------------------------------------------------------------------------------- /test/flex-align-self.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('row - align text to center', t => { 7 | const output = renderToString( 8 | 9 | 10 | Test 11 | 12 | , 13 | ); 14 | 15 | t.is(output, '\nTest\n'); 16 | }); 17 | 18 | test('row - align multiple text nodes to center', t => { 19 | const output = renderToString( 20 | 21 | 22 | A 23 | B 24 | 25 | , 26 | ); 27 | 28 | t.is(output, '\nAB\n'); 29 | }); 30 | 31 | test('row - align text to bottom', t => { 32 | const output = renderToString( 33 | 34 | 35 | Test 36 | 37 | , 38 | ); 39 | 40 | t.is(output, '\n\nTest'); 41 | }); 42 | 43 | test('row - align multiple text nodes to bottom', t => { 44 | const output = renderToString( 45 | 46 | 47 | A 48 | B 49 | 50 | , 51 | ); 52 | 53 | t.is(output, '\n\nAB'); 54 | }); 55 | 56 | test('column - align text to center', t => { 57 | const output = renderToString( 58 | 59 | 60 | Test 61 | 62 | , 63 | ); 64 | 65 | t.is(output, ' Test'); 66 | }); 67 | 68 | test('column - align text to right', t => { 69 | const output = renderToString( 70 | 71 | 72 | Test 73 | 74 | , 75 | ); 76 | 77 | t.is(output, ' Test'); 78 | }); 79 | -------------------------------------------------------------------------------- /test/flex-direction.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('direction row', t => { 7 | const output = renderToString( 8 | 9 | A 10 | B 11 | , 12 | ); 13 | 14 | t.is(output, 'AB'); 15 | }); 16 | 17 | test('direction row reverse', t => { 18 | const output = renderToString( 19 | 20 | A 21 | B 22 | , 23 | ); 24 | 25 | t.is(output, ' BA'); 26 | }); 27 | 28 | test('direction column', t => { 29 | const output = renderToString( 30 | 31 | A 32 | B 33 | , 34 | ); 35 | 36 | t.is(output, 'A\nB'); 37 | }); 38 | 39 | test('direction column reverse', t => { 40 | const output = renderToString( 41 | 42 | A 43 | B 44 | , 45 | ); 46 | 47 | t.is(output, '\n\nB\nA'); 48 | }); 49 | 50 | test('don’t squash text nodes when column direction is applied', t => { 51 | const output = renderToString( 52 | 53 | A 54 | B 55 | , 56 | ); 57 | 58 | t.is(output, 'A\nB'); 59 | }); 60 | -------------------------------------------------------------------------------- /test/flex-justify-content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import chalk from 'chalk'; 4 | import {Box, Text} from '../src/index.js'; 5 | import {renderToString} from './helpers/render-to-string.js'; 6 | 7 | test('row - align text to center', t => { 8 | const output = renderToString( 9 | 10 | Test 11 | , 12 | ); 13 | 14 | t.is(output, ' Test'); 15 | }); 16 | 17 | test('row - align multiple text nodes to center', t => { 18 | const output = renderToString( 19 | 20 | A 21 | B 22 | , 23 | ); 24 | 25 | t.is(output, ' AB'); 26 | }); 27 | 28 | test('row - align text to right', t => { 29 | const output = renderToString( 30 | 31 | Test 32 | , 33 | ); 34 | 35 | t.is(output, ' Test'); 36 | }); 37 | 38 | test('row - align multiple text nodes to right', t => { 39 | const output = renderToString( 40 | 41 | A 42 | B 43 | , 44 | ); 45 | 46 | t.is(output, ' AB'); 47 | }); 48 | 49 | test('row - align two text nodes on the edges', t => { 50 | const output = renderToString( 51 | 52 | A 53 | B 54 | , 55 | ); 56 | 57 | t.is(output, 'A B'); 58 | }); 59 | 60 | test('row - space evenly two text nodes', t => { 61 | const output = renderToString( 62 | 63 | A 64 | B 65 | , 66 | ); 67 | 68 | t.is(output, ' A B'); 69 | }); 70 | 71 | // Yoga has a bug, where first child in a container with space-around doesn't have 72 | // the correct X coordinate and measure function is used on that child node 73 | test.failing('row - align two text nodes with equal space around them', t => { 74 | const output = renderToString( 75 | 76 | A 77 | B 78 | , 79 | ); 80 | 81 | t.is(output, ' A B'); 82 | }); 83 | 84 | test('row - align colored text node when text is squashed', t => { 85 | const output = renderToString( 86 | 87 | X 88 | , 89 | ); 90 | 91 | t.is(output, ` ${chalk.green('X')}`); 92 | }); 93 | 94 | test('column - align text to center', t => { 95 | const output = renderToString( 96 | 97 | Test 98 | , 99 | ); 100 | 101 | t.is(output, '\nTest\n'); 102 | }); 103 | 104 | test('column - align text to bottom', t => { 105 | const output = renderToString( 106 | 107 | Test 108 | , 109 | ); 110 | 111 | t.is(output, '\n\nTest'); 112 | }); 113 | 114 | test('column - align two text nodes on the edges', t => { 115 | const output = renderToString( 116 | 117 | A 118 | B 119 | , 120 | ); 121 | 122 | t.is(output, 'A\n\n\nB'); 123 | }); 124 | 125 | // Yoga has a bug, where first child in a container with space-around doesn't have 126 | // the correct X coordinate and measure function is used on that child node 127 | test.failing( 128 | 'column - align two text nodes with equal space around them', 129 | t => { 130 | const output = renderToString( 131 | 132 | A 133 | B 134 | , 135 | ); 136 | 137 | t.is(output, '\nA\n\nB\n'); 138 | }, 139 | ); 140 | -------------------------------------------------------------------------------- /test/flex-wrap.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('row - no wrap', t => { 7 | const output = renderToString( 8 | 9 | A 10 | BC 11 | , 12 | ); 13 | 14 | t.is(output, 'BC\n'); 15 | }); 16 | 17 | test('column - no wrap', t => { 18 | const output = renderToString( 19 | 20 | A 21 | B 22 | C 23 | , 24 | ); 25 | 26 | t.is(output, 'B\nC'); 27 | }); 28 | 29 | test('row - wrap content', t => { 30 | const output = renderToString( 31 | 32 | A 33 | BC 34 | , 35 | ); 36 | 37 | t.is(output, 'A\nBC'); 38 | }); 39 | 40 | test('column - wrap content', t => { 41 | const output = renderToString( 42 | 43 | A 44 | B 45 | C 46 | , 47 | ); 48 | 49 | t.is(output, 'AC\nB'); 50 | }); 51 | 52 | test('column - wrap content reverse', t => { 53 | const output = renderToString( 54 | 55 | A 56 | B 57 | C 58 | , 59 | ); 60 | 61 | t.is(output, ' CA\n B'); 62 | }); 63 | 64 | test('row - wrap content reverse', t => { 65 | const output = renderToString( 66 | 67 | A 68 | B 69 | C 70 | , 71 | ); 72 | 73 | t.is(output, '\nC\nAB'); 74 | }); 75 | -------------------------------------------------------------------------------- /test/flex.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('grow equally', t => { 7 | const output = renderToString( 8 | 9 | 10 | A 11 | 12 | 13 | B 14 | 15 | , 16 | ); 17 | 18 | t.is(output, 'A B'); 19 | }); 20 | 21 | test('grow one element', t => { 22 | const output = renderToString( 23 | 24 | 25 | A 26 | 27 | B 28 | , 29 | ); 30 | 31 | t.is(output, 'A B'); 32 | }); 33 | 34 | test('dont shrink', t => { 35 | const output = renderToString( 36 | 37 | 38 | A 39 | 40 | 41 | B 42 | 43 | 44 | C 45 | 46 | , 47 | ); 48 | 49 | t.is(output, 'A B C'); 50 | }); 51 | 52 | test('shrink equally', t => { 53 | const output = renderToString( 54 | 55 | 56 | A 57 | 58 | 59 | B 60 | 61 | C 62 | , 63 | ); 64 | 65 | t.is(output, 'A B C'); 66 | }); 67 | 68 | test('set flex basis with flexDirection="row" container', t => { 69 | const output = renderToString( 70 | 71 | 72 | A 73 | 74 | B 75 | , 76 | ); 77 | 78 | t.is(output, 'A B'); 79 | }); 80 | 81 | test('set flex basis in percent with flexDirection="row" container', t => { 82 | const output = renderToString( 83 | 84 | 85 | A 86 | 87 | B 88 | , 89 | ); 90 | 91 | t.is(output, 'A B'); 92 | }); 93 | 94 | test('set flex basis with flexDirection="column" container', t => { 95 | const output = renderToString( 96 | 97 | 98 | A 99 | 100 | B 101 | , 102 | ); 103 | 104 | t.is(output, 'A\n\n\nB\n\n'); 105 | }); 106 | 107 | test('set flex basis in percent with flexDirection="column" container', t => { 108 | const output = renderToString( 109 | 110 | 111 | A 112 | 113 | B 114 | , 115 | ); 116 | 117 | t.is(output, 'A\n\n\nB\n\n'); 118 | }); 119 | -------------------------------------------------------------------------------- /test/gap.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('gap', t => { 7 | const output = renderToString( 8 | 9 | A 10 | B 11 | C 12 | , 13 | ); 14 | 15 | t.is(output, 'A B\n\nC'); 16 | }); 17 | 18 | test('column gap', t => { 19 | const output = renderToString( 20 | 21 | A 22 | B 23 | , 24 | ); 25 | 26 | t.is(output, 'A B'); 27 | }); 28 | 29 | test('row gap', t => { 30 | const output = renderToString( 31 | 32 | A 33 | B 34 | , 35 | ); 36 | 37 | t.is(output, 'A\n\nB'); 38 | }); 39 | -------------------------------------------------------------------------------- /test/helpers/create-stdout.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events'; 2 | import {spy} from 'sinon'; 3 | 4 | // Fake process.stdout 5 | type FakeStdout = { 6 | get: () => string; 7 | } & NodeJS.WriteStream; 8 | 9 | const createStdout = (columns?: number): FakeStdout => { 10 | const stdout = new EventEmitter() as unknown as FakeStdout; 11 | stdout.columns = columns ?? 100; 12 | 13 | const write = spy(); 14 | stdout.write = write; 15 | 16 | stdout.get = () => write.lastCall.args[0] as string; 17 | 18 | return stdout; 19 | }; 20 | 21 | export default createStdout; 22 | -------------------------------------------------------------------------------- /test/helpers/render-to-string.ts: -------------------------------------------------------------------------------- 1 | import {render} from '../../src/index.js'; 2 | import createStdout from './create-stdout.js'; 3 | 4 | export const renderToString: ( 5 | node: React.JSX.Element, 6 | options?: {columns: number}, 7 | ) => string = (node, options) => { 8 | const stdout = createStdout(options?.columns ?? 100); 9 | 10 | render(node, { 11 | stdout, 12 | debug: true, 13 | }); 14 | 15 | const output = stdout.get(); 16 | return output; 17 | }; 18 | -------------------------------------------------------------------------------- /test/helpers/run.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import {createRequire} from 'node:module'; 3 | import path from 'node:path'; 4 | import url from 'node:url'; 5 | 6 | const require = createRequire(import.meta.url); 7 | 8 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 9 | const {spawn} = require('node-pty') as typeof import('node-pty'); 10 | 11 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); 12 | 13 | type Run = ( 14 | fixture: string, 15 | props?: {env?: Record; columns?: number}, 16 | ) => Promise; 17 | 18 | export const run: Run = async (fixture, props) => { 19 | const env: Record = { 20 | ...(process.env as Record), 21 | // eslint-disable-next-line @typescript-eslint/naming-convention 22 | CI: 'false', 23 | ...props?.env, 24 | // eslint-disable-next-line @typescript-eslint/naming-convention 25 | NODE_NO_WARNINGS: '1', 26 | }; 27 | 28 | return new Promise((resolve, reject) => { 29 | const term = spawn( 30 | 'node', 31 | [ 32 | '--loader=ts-node/esm', 33 | path.join(__dirname, `/../fixtures/${fixture}.tsx`), 34 | ], 35 | { 36 | name: 'xterm-color', 37 | cols: typeof props?.columns === 'number' ? props.columns : 100, 38 | cwd: __dirname, 39 | env, 40 | }, 41 | ); 42 | 43 | let output = ''; 44 | 45 | term.onData(data => { 46 | output += data; 47 | }); 48 | 49 | term.onExit(({exitCode}) => { 50 | if (exitCode === 0) { 51 | resolve(output); 52 | return; 53 | } 54 | 55 | reject(new Error(`Process exited with a non-zero code: ${exitCode}`)); 56 | }); 57 | }); 58 | }; 59 | -------------------------------------------------------------------------------- /test/hooks.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import url from 'node:url'; 3 | import path from 'node:path'; 4 | import test, {type ExecutionContext} from 'ava'; 5 | import stripAnsi from 'strip-ansi'; 6 | import {spawn} from 'node-pty'; 7 | 8 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); 9 | 10 | const term = (fixture: string, args: string[] = []) => { 11 | let resolve: (value?: any) => void; 12 | let reject: (error?: Error) => void; 13 | 14 | // eslint-disable-next-line promise/param-names 15 | const exitPromise = new Promise((resolve2, reject2) => { 16 | resolve = resolve2; 17 | reject = reject2; 18 | }); 19 | 20 | const env: Record = { 21 | ...process.env, 22 | // eslint-disable-next-line @typescript-eslint/naming-convention 23 | NODE_NO_WARNINGS: '1', 24 | // eslint-disable-next-line @typescript-eslint/naming-convention 25 | CI: 'false', 26 | }; 27 | 28 | const ps = spawn( 29 | 'node', 30 | [ 31 | '--loader=ts-node/esm', 32 | path.join(__dirname, `./fixtures/${fixture}.tsx`), 33 | ...args, 34 | ], 35 | { 36 | name: 'xterm-color', 37 | cols: 100, 38 | cwd: __dirname, 39 | env, 40 | }, 41 | ); 42 | 43 | const result = { 44 | write(input: string) { 45 | // Give TS and Ink time to start up and render UI 46 | // TODO: Send a signal from the Ink process when it's ready to accept input instead 47 | setTimeout(() => { 48 | ps.write(input); 49 | }, 3000); 50 | }, 51 | output: '', 52 | waitForExit: async () => exitPromise, 53 | }; 54 | 55 | ps.onData(data => { 56 | result.output += data; 57 | }); 58 | 59 | ps.onExit(({exitCode}) => { 60 | if (exitCode === 0) { 61 | resolve(); 62 | return; 63 | } 64 | 65 | reject(new Error(`Process exited with non-zero exit code: ${exitCode}`)); 66 | }); 67 | 68 | return result; 69 | }; 70 | 71 | test.serial('useInput - handle lowercase character', async t => { 72 | const ps = term('use-input', ['lowercase']); 73 | ps.write('q'); 74 | await ps.waitForExit(); 75 | t.true(ps.output.includes('exited')); 76 | }); 77 | 78 | test.serial('useInput - handle uppercase character', async t => { 79 | const ps = term('use-input', ['uppercase']); 80 | ps.write('Q'); 81 | await ps.waitForExit(); 82 | t.true(ps.output.includes('exited')); 83 | }); 84 | 85 | test.serial( 86 | 'useInput - \r should not count as an uppercase character', 87 | async t => { 88 | const ps = term('use-input', ['uppercase']); 89 | ps.write('\r'); 90 | await ps.waitForExit(); 91 | t.true(ps.output.includes('exited')); 92 | }, 93 | ); 94 | 95 | test.serial('useInput - pasted carriage return', async t => { 96 | const ps = term('use-input', ['pastedCarriageReturn']); 97 | ps.write('\rtest'); 98 | await ps.waitForExit(); 99 | t.true(ps.output.includes('exited')); 100 | }); 101 | 102 | test.serial('useInput - pasted tab', async t => { 103 | const ps = term('use-input', ['pastedTab']); 104 | ps.write('\ttest'); 105 | await ps.waitForExit(); 106 | t.true(ps.output.includes('exited')); 107 | }); 108 | 109 | test.serial('useInput - handle escape', async t => { 110 | const ps = term('use-input', ['escape']); 111 | ps.write('\u001B'); 112 | await ps.waitForExit(); 113 | t.true(ps.output.includes('exited')); 114 | }); 115 | 116 | test.serial('useInput - handle ctrl', async t => { 117 | const ps = term('use-input', ['ctrl']); 118 | ps.write('\u0006'); 119 | await ps.waitForExit(); 120 | t.true(ps.output.includes('exited')); 121 | }); 122 | 123 | test.serial('useInput - handle meta', async t => { 124 | const ps = term('use-input', ['meta']); 125 | ps.write('\u001Bm'); 126 | await ps.waitForExit(); 127 | t.true(ps.output.includes('exited')); 128 | }); 129 | 130 | test.serial('useInput - handle up arrow', async t => { 131 | const ps = term('use-input', ['upArrow']); 132 | ps.write('\u001B[A'); 133 | await ps.waitForExit(); 134 | t.true(ps.output.includes('exited')); 135 | }); 136 | 137 | test.serial('useInput - handle down arrow', async t => { 138 | const ps = term('use-input', ['downArrow']); 139 | ps.write('\u001B[B'); 140 | await ps.waitForExit(); 141 | t.true(ps.output.includes('exited')); 142 | }); 143 | 144 | test.serial('useInput - handle left arrow', async t => { 145 | const ps = term('use-input', ['leftArrow']); 146 | ps.write('\u001B[D'); 147 | await ps.waitForExit(); 148 | t.true(ps.output.includes('exited')); 149 | }); 150 | 151 | test.serial('useInput - handle right arrow', async t => { 152 | const ps = term('use-input', ['rightArrow']); 153 | ps.write('\u001B[C'); 154 | await ps.waitForExit(); 155 | t.true(ps.output.includes('exited')); 156 | }); 157 | 158 | test.serial('useInput - handle meta + up arrow', async t => { 159 | const ps = term('use-input', ['upArrowMeta']); 160 | ps.write('\u001B\u001B[A'); 161 | await ps.waitForExit(); 162 | t.true(ps.output.includes('exited')); 163 | }); 164 | 165 | test.serial('useInput - handle meta + down arrow', async t => { 166 | const ps = term('use-input', ['downArrowMeta']); 167 | ps.write('\u001B\u001B[B'); 168 | await ps.waitForExit(); 169 | t.true(ps.output.includes('exited')); 170 | }); 171 | 172 | test.serial('useInput - handle meta + left arrow', async t => { 173 | const ps = term('use-input', ['leftArrowMeta']); 174 | ps.write('\u001B\u001B[D'); 175 | await ps.waitForExit(); 176 | t.true(ps.output.includes('exited')); 177 | }); 178 | 179 | test.serial('useInput - handle meta + right arrow', async t => { 180 | const ps = term('use-input', ['rightArrowMeta']); 181 | ps.write('\u001B\u001B[C'); 182 | await ps.waitForExit(); 183 | t.true(ps.output.includes('exited')); 184 | }); 185 | 186 | test.serial('useInput - handle ctrl + up arrow', async t => { 187 | const ps = term('use-input', ['upArrowCtrl']); 188 | ps.write('\u001B[1;5A'); 189 | await ps.waitForExit(); 190 | t.true(ps.output.includes('exited')); 191 | }); 192 | 193 | test.serial('useInput - handle ctrl + down arrow', async t => { 194 | const ps = term('use-input', ['downArrowCtrl']); 195 | ps.write('\u001B[1;5B'); 196 | await ps.waitForExit(); 197 | t.true(ps.output.includes('exited')); 198 | }); 199 | 200 | test.serial('useInput - handle ctrl + left arrow', async t => { 201 | const ps = term('use-input', ['leftArrowCtrl']); 202 | ps.write('\u001B[1;5D'); 203 | await ps.waitForExit(); 204 | t.true(ps.output.includes('exited')); 205 | }); 206 | 207 | test.serial('useInput - handle ctrl + right arrow', async t => { 208 | const ps = term('use-input', ['rightArrowCtrl']); 209 | ps.write('\u001B[1;5C'); 210 | await ps.waitForExit(); 211 | t.true(ps.output.includes('exited')); 212 | }); 213 | 214 | test.serial('useInput - handle page down', async t => { 215 | const ps = term('use-input', ['pageDown']); 216 | ps.write('\u001B[6~'); 217 | await ps.waitForExit(); 218 | t.true(ps.output.includes('exited')); 219 | }); 220 | 221 | test.serial('useInput - handle page up', async t => { 222 | const ps = term('use-input', ['pageUp']); 223 | ps.write('\u001B[5~'); 224 | await ps.waitForExit(); 225 | t.true(ps.output.includes('exited')); 226 | }); 227 | 228 | test.serial('useInput - handle tab', async t => { 229 | const ps = term('use-input', ['tab']); 230 | ps.write('\t'); 231 | await ps.waitForExit(); 232 | t.true(ps.output.includes('exited')); 233 | }); 234 | 235 | test.serial('useInput - handle shift + tab', async t => { 236 | const ps = term('use-input', ['shiftTab']); 237 | ps.write('\u001B[Z'); 238 | await ps.waitForExit(); 239 | t.true(ps.output.includes('exited')); 240 | }); 241 | 242 | test.serial('useInput - handle backspace', async t => { 243 | const ps = term('use-input', ['backspace']); 244 | ps.write('\u0008'); 245 | await ps.waitForExit(); 246 | t.true(ps.output.includes('exited')); 247 | }); 248 | 249 | test.serial('useInput - handle delete', async t => { 250 | const ps = term('use-input', ['delete']); 251 | ps.write('\u007F'); 252 | await ps.waitForExit(); 253 | t.true(ps.output.includes('exited')); 254 | }); 255 | 256 | test.serial('useInput - handle remove (delete)', async t => { 257 | const ps = term('use-input', ['remove']); 258 | ps.write('\u001B[3~'); 259 | await ps.waitForExit(); 260 | t.true(ps.output.includes('exited')); 261 | }); 262 | 263 | test.serial('useInput - ignore input if not active', async t => { 264 | const ps = term('use-input-multiple'); 265 | ps.write('x'); 266 | await ps.waitForExit(); 267 | t.false(ps.output.includes('xx')); 268 | t.true(ps.output.includes('x')); 269 | t.true(ps.output.includes('exited')); 270 | }); 271 | 272 | // For some reason this test is flaky, so we have to resort to using `t.try` to run it multiple times 273 | test.serial( 274 | 'useInput - handle Ctrl+C when `exitOnCtrlC` is `false`', 275 | async t => { 276 | const run = async (tt: ExecutionContext) => { 277 | const ps = term('use-input-ctrl-c'); 278 | ps.write('\u0003'); 279 | await ps.waitForExit(); 280 | tt.true(ps.output.includes('exited')); 281 | }; 282 | 283 | const firstTry = await t.try(run); 284 | 285 | if (firstTry.passed) { 286 | firstTry.commit(); 287 | return; 288 | } 289 | 290 | firstTry.discard(); 291 | 292 | const secondTry = await t.try(run); 293 | 294 | if (secondTry.passed) { 295 | secondTry.commit(); 296 | return; 297 | } 298 | 299 | secondTry.discard(); 300 | 301 | const thirdTry = await t.try(run); 302 | thirdTry.commit(); 303 | }, 304 | ); 305 | 306 | test.serial('useStdout - write to stdout', async t => { 307 | const ps = term('use-stdout'); 308 | await ps.waitForExit(); 309 | 310 | const lines = stripAnsi(ps.output).split('\r\n'); 311 | 312 | t.deepEqual(lines.slice(1, -1), [ 313 | 'Hello from Ink to stdout', 314 | 'Hello World', 315 | 'exited', 316 | ]); 317 | }); 318 | 319 | // `node-pty` doesn't support streaming stderr output, so I need to figure out 320 | // how to test useStderr() hook. child_process.spawn() can't be used, because 321 | // Ink fails with "raw mode unsupported" error. 322 | test.todo('useStderr - write to stderr'); 323 | -------------------------------------------------------------------------------- /test/margin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('margin', t => { 7 | const output = renderToString( 8 | 9 | X 10 | , 11 | ); 12 | 13 | t.is(output, '\n\n X\n\n'); 14 | }); 15 | 16 | test('margin X', t => { 17 | const output = renderToString( 18 | 19 | 20 | X 21 | 22 | Y 23 | , 24 | ); 25 | 26 | t.is(output, ' X Y'); 27 | }); 28 | 29 | test('margin Y', t => { 30 | const output = renderToString( 31 | 32 | X 33 | , 34 | ); 35 | 36 | t.is(output, '\n\nX\n\n'); 37 | }); 38 | 39 | test('margin top', t => { 40 | const output = renderToString( 41 | 42 | X 43 | , 44 | ); 45 | 46 | t.is(output, '\n\nX'); 47 | }); 48 | 49 | test('margin bottom', t => { 50 | const output = renderToString( 51 | 52 | X 53 | , 54 | ); 55 | 56 | t.is(output, 'X\n\n'); 57 | }); 58 | 59 | test('margin left', t => { 60 | const output = renderToString( 61 | 62 | X 63 | , 64 | ); 65 | 66 | t.is(output, ' X'); 67 | }); 68 | 69 | test('margin right', t => { 70 | const output = renderToString( 71 | 72 | 73 | X 74 | 75 | Y 76 | , 77 | ); 78 | 79 | t.is(output, 'X Y'); 80 | }); 81 | 82 | test('nested margin', t => { 83 | const output = renderToString( 84 | 85 | 86 | X 87 | 88 | , 89 | ); 90 | 91 | t.is(output, '\n\n\n\n X\n\n\n\n'); 92 | }); 93 | 94 | test('margin with multiline string', t => { 95 | const output = renderToString( 96 | 97 | {'A\nB'} 98 | , 99 | ); 100 | 101 | t.is(output, '\n\n A\n B\n\n'); 102 | }); 103 | 104 | test('apply margin to text with newlines', t => { 105 | const output = renderToString( 106 | 107 | Hello{'\n'}World 108 | , 109 | ); 110 | t.is(output, '\n Hello\n World\n'); 111 | }); 112 | 113 | test('apply margin to wrapped text', t => { 114 | const output = renderToString( 115 | 116 | Hello World 117 | , 118 | ); 119 | 120 | t.is(output, '\n Hello\n World\n'); 121 | }); 122 | -------------------------------------------------------------------------------- /test/measure-element.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState, useRef, useEffect} from 'react'; 2 | import test from 'ava'; 3 | import delay from 'delay'; 4 | import stripAnsi from 'strip-ansi'; 5 | import { 6 | Box, 7 | Text, 8 | render, 9 | measureElement, 10 | type DOMElement, 11 | } from '../src/index.js'; 12 | import createStdout from './helpers/create-stdout.js'; 13 | 14 | test('measure element', async t => { 15 | const stdout = createStdout(); 16 | 17 | function Test() { 18 | const [width, setWidth] = useState(0); 19 | const ref = useRef(null); 20 | 21 | useEffect(() => { 22 | if (!ref.current) { 23 | return; 24 | } 25 | 26 | setWidth(measureElement(ref.current).width); 27 | }, []); 28 | 29 | return ( 30 | 31 | Width: {width} 32 | 33 | ); 34 | } 35 | 36 | render(, {stdout, debug: true}); 37 | t.is((stdout.write as any).firstCall.args[0], 'Width: 0'); 38 | await delay(100); 39 | t.is((stdout.write as any).lastCall.args[0], 'Width: 100'); 40 | }); 41 | 42 | test.serial('calculate layout while rendering is throttled', async t => { 43 | const stdout = createStdout(); 44 | 45 | function Test() { 46 | const [width, setWidth] = useState(0); 47 | const ref = useRef(null); 48 | 49 | useEffect(() => { 50 | if (!ref.current) { 51 | return; 52 | } 53 | 54 | setWidth(measureElement(ref.current).width); 55 | }, []); 56 | 57 | return ( 58 | 59 | Width: {width} 60 | 61 | ); 62 | } 63 | 64 | const {rerender} = render(null, {stdout, patchConsole: false}); 65 | rerender(); 66 | await delay(50); 67 | 68 | t.is( 69 | stripAnsi((stdout.write as any).lastCall.firstArg as string).trim(), 70 | 'Width: 100', 71 | ); 72 | }); 73 | -------------------------------------------------------------------------------- /test/padding.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('padding', t => { 7 | const output = renderToString( 8 | 9 | X 10 | , 11 | ); 12 | 13 | t.is(output, '\n\n X\n\n'); 14 | }); 15 | 16 | test('padding X', t => { 17 | const output = renderToString( 18 | 19 | 20 | X 21 | 22 | Y 23 | , 24 | ); 25 | 26 | t.is(output, ' X Y'); 27 | }); 28 | 29 | test('padding Y', t => { 30 | const output = renderToString( 31 | 32 | X 33 | , 34 | ); 35 | 36 | t.is(output, '\n\nX\n\n'); 37 | }); 38 | 39 | test('padding top', t => { 40 | const output = renderToString( 41 | 42 | X 43 | , 44 | ); 45 | 46 | t.is(output, '\n\nX'); 47 | }); 48 | 49 | test('padding bottom', t => { 50 | const output = renderToString( 51 | 52 | X 53 | , 54 | ); 55 | 56 | t.is(output, 'X\n\n'); 57 | }); 58 | 59 | test('padding left', t => { 60 | const output = renderToString( 61 | 62 | X 63 | , 64 | ); 65 | 66 | t.is(output, ' X'); 67 | }); 68 | 69 | test('padding right', t => { 70 | const output = renderToString( 71 | 72 | 73 | X 74 | 75 | Y 76 | , 77 | ); 78 | 79 | t.is(output, 'X Y'); 80 | }); 81 | 82 | test('nested padding', t => { 83 | const output = renderToString( 84 | 85 | 86 | X 87 | 88 | , 89 | ); 90 | 91 | t.is(output, '\n\n\n\n X\n\n\n\n'); 92 | }); 93 | 94 | test('padding with multiline string', t => { 95 | const output = renderToString( 96 | 97 | {'A\nB'} 98 | , 99 | ); 100 | 101 | t.is(output, '\n\n A\n B\n\n'); 102 | }); 103 | 104 | test('apply padding to text with newlines', t => { 105 | const output = renderToString( 106 | 107 | Hello{'\n'}World 108 | , 109 | ); 110 | t.is(output, '\n Hello\n World\n'); 111 | }); 112 | 113 | test('apply padding to wrapped text', t => { 114 | const output = renderToString( 115 | 116 | Hello World 117 | , 118 | ); 119 | 120 | t.is(output, '\n Hel\n lo\n Wor\n ld\n'); 121 | }); 122 | -------------------------------------------------------------------------------- /test/reconciler.tsx: -------------------------------------------------------------------------------- 1 | import React, {Suspense} from 'react'; 2 | import test from 'ava'; 3 | import chalk from 'chalk'; 4 | import {Box, Text, render} from '../src/index.js'; 5 | import createStdout from './helpers/create-stdout.js'; 6 | 7 | test('update child', t => { 8 | function Test({update}: {readonly update?: boolean}) { 9 | return {update ? 'B' : 'A'}; 10 | } 11 | 12 | const stdoutActual = createStdout(); 13 | const stdoutExpected = createStdout(); 14 | 15 | const actual = render(, { 16 | stdout: stdoutActual, 17 | debug: true, 18 | }); 19 | 20 | const expected = render(A, { 21 | stdout: stdoutExpected, 22 | debug: true, 23 | }); 24 | 25 | t.is( 26 | (stdoutActual.write as any).lastCall.args[0], 27 | (stdoutExpected.write as any).lastCall.args[0], 28 | ); 29 | 30 | actual.rerender(); 31 | expected.rerender(B); 32 | 33 | t.is( 34 | (stdoutActual.write as any).lastCall.args[0], 35 | (stdoutExpected.write as any).lastCall.args[0], 36 | ); 37 | }); 38 | 39 | test('update text node', t => { 40 | function Test({update}: {readonly update?: boolean}) { 41 | return ( 42 | 43 | Hello 44 | {update ? 'B' : 'A'} 45 | 46 | ); 47 | } 48 | 49 | const stdoutActual = createStdout(); 50 | const stdoutExpected = createStdout(); 51 | 52 | const actual = render(, { 53 | stdout: stdoutActual, 54 | debug: true, 55 | }); 56 | 57 | const expected = render(Hello A, { 58 | stdout: stdoutExpected, 59 | debug: true, 60 | }); 61 | 62 | t.is( 63 | (stdoutActual.write as any).lastCall.args[0], 64 | (stdoutExpected.write as any).lastCall.args[0], 65 | ); 66 | 67 | actual.rerender(); 68 | expected.rerender(Hello B); 69 | 70 | t.is( 71 | (stdoutActual.write as any).lastCall.args[0], 72 | (stdoutExpected.write as any).lastCall.args[0], 73 | ); 74 | }); 75 | 76 | test('append child', t => { 77 | function Test({append}: {readonly append?: boolean}) { 78 | if (append) { 79 | return ( 80 | 81 | A 82 | B 83 | 84 | ); 85 | } 86 | 87 | return ( 88 | 89 | A 90 | 91 | ); 92 | } 93 | 94 | const stdoutActual = createStdout(); 95 | const stdoutExpected = createStdout(); 96 | 97 | const actual = render(, { 98 | stdout: stdoutActual, 99 | debug: true, 100 | }); 101 | 102 | const expected = render( 103 | 104 | A 105 | , 106 | { 107 | stdout: stdoutExpected, 108 | debug: true, 109 | }, 110 | ); 111 | 112 | t.is( 113 | (stdoutActual.write as any).lastCall.args[0], 114 | (stdoutExpected.write as any).lastCall.args[0], 115 | ); 116 | 117 | actual.rerender(); 118 | 119 | expected.rerender( 120 | 121 | A 122 | B 123 | , 124 | ); 125 | 126 | t.is( 127 | (stdoutActual.write as any).lastCall.args[0], 128 | (stdoutExpected.write as any).lastCall.args[0], 129 | ); 130 | }); 131 | 132 | test('insert child between other children', t => { 133 | function Test({insert}: {readonly insert?: boolean}) { 134 | if (insert) { 135 | return ( 136 | 137 | A 138 | B 139 | C 140 | 141 | ); 142 | } 143 | 144 | return ( 145 | 146 | A 147 | C 148 | 149 | ); 150 | } 151 | 152 | const stdoutActual = createStdout(); 153 | const stdoutExpected = createStdout(); 154 | 155 | const actual = render(, { 156 | stdout: stdoutActual, 157 | debug: true, 158 | }); 159 | 160 | const expected = render( 161 | 162 | A 163 | C 164 | , 165 | { 166 | stdout: stdoutExpected, 167 | debug: true, 168 | }, 169 | ); 170 | 171 | t.is( 172 | (stdoutActual.write as any).lastCall.args[0], 173 | (stdoutExpected.write as any).lastCall.args[0], 174 | ); 175 | 176 | actual.rerender(); 177 | 178 | expected.rerender( 179 | 180 | A 181 | B 182 | C 183 | , 184 | ); 185 | 186 | t.is( 187 | (stdoutActual.write as any).lastCall.args[0], 188 | (stdoutExpected.write as any).lastCall.args[0], 189 | ); 190 | }); 191 | 192 | test('remove child', t => { 193 | function Test({remove}: {readonly remove?: boolean}) { 194 | if (remove) { 195 | return ( 196 | 197 | A 198 | 199 | ); 200 | } 201 | 202 | return ( 203 | 204 | A 205 | B 206 | 207 | ); 208 | } 209 | 210 | const stdoutActual = createStdout(); 211 | const stdoutExpected = createStdout(); 212 | 213 | const actual = render(, { 214 | stdout: stdoutActual, 215 | debug: true, 216 | }); 217 | 218 | const expected = render( 219 | 220 | A 221 | B 222 | , 223 | { 224 | stdout: stdoutExpected, 225 | debug: true, 226 | }, 227 | ); 228 | 229 | t.is( 230 | (stdoutActual.write as any).lastCall.args[0], 231 | (stdoutExpected.write as any).lastCall.args[0], 232 | ); 233 | 234 | actual.rerender(); 235 | 236 | expected.rerender( 237 | 238 | A 239 | , 240 | ); 241 | 242 | t.is( 243 | (stdoutActual.write as any).lastCall.args[0], 244 | (stdoutExpected.write as any).lastCall.args[0], 245 | ); 246 | }); 247 | 248 | test('reorder children', t => { 249 | function Test({reorder}: {readonly reorder?: boolean}) { 250 | if (reorder) { 251 | return ( 252 | 253 | B 254 | A 255 | 256 | ); 257 | } 258 | 259 | return ( 260 | 261 | A 262 | B 263 | 264 | ); 265 | } 266 | 267 | const stdoutActual = createStdout(); 268 | const stdoutExpected = createStdout(); 269 | 270 | const actual = render(, { 271 | stdout: stdoutActual, 272 | debug: true, 273 | }); 274 | 275 | const expected = render( 276 | 277 | A 278 | B 279 | , 280 | { 281 | stdout: stdoutExpected, 282 | debug: true, 283 | }, 284 | ); 285 | 286 | t.is( 287 | (stdoutActual.write as any).lastCall.args[0], 288 | (stdoutExpected.write as any).lastCall.args[0], 289 | ); 290 | 291 | actual.rerender(); 292 | 293 | expected.rerender( 294 | 295 | B 296 | A 297 | , 298 | ); 299 | 300 | t.is( 301 | (stdoutActual.write as any).lastCall.args[0], 302 | (stdoutExpected.write as any).lastCall.args[0], 303 | ); 304 | }); 305 | 306 | test('replace child node with text', t => { 307 | const stdout = createStdout(); 308 | 309 | function Dynamic({replace}: {readonly replace?: boolean}) { 310 | return {replace ? 'x' : test}; 311 | } 312 | 313 | const {rerender} = render(, { 314 | stdout, 315 | debug: true, 316 | }); 317 | 318 | t.is((stdout.write as any).lastCall.args[0], chalk.green('test')); 319 | 320 | rerender(); 321 | t.is((stdout.write as any).lastCall.args[0], 'x'); 322 | }); 323 | 324 | test('support suspense', async t => { 325 | const stdout = createStdout(); 326 | 327 | let promise: Promise | undefined; 328 | let state: 'pending' | 'done' | undefined; 329 | let value: string | undefined; 330 | 331 | const read = () => { 332 | if (!promise) { 333 | promise = new Promise(resolve => { 334 | setTimeout(resolve, 500); 335 | }); 336 | 337 | state = 'pending'; 338 | 339 | (async () => { 340 | await promise; 341 | state = 'done'; 342 | value = 'Hello World'; 343 | })(); 344 | } 345 | 346 | if (state === 'done') { 347 | return value; 348 | } 349 | 350 | // eslint-disable-next-line @typescript-eslint/only-throw-error 351 | throw promise; 352 | }; 353 | 354 | function Suspendable() { 355 | return {read()}; 356 | } 357 | 358 | function Test() { 359 | return ( 360 | Loading}> 361 | 362 | 363 | ); 364 | } 365 | 366 | const out = render(, { 367 | stdout, 368 | debug: true, 369 | }); 370 | 371 | t.is((stdout.write as any).lastCall.args[0], 'Loading'); 372 | 373 | await promise; 374 | out.rerender(); 375 | 376 | t.is((stdout.write as any).lastCall.args[0], 'Hello World'); 377 | }); 378 | -------------------------------------------------------------------------------- /test/render.tsx: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import url from 'node:url'; 3 | import * as path from 'node:path'; 4 | import {createRequire} from 'node:module'; 5 | import test from 'ava'; 6 | import React from 'react'; 7 | import ansiEscapes from 'ansi-escapes'; 8 | import stripAnsi from 'strip-ansi'; 9 | import boxen from 'boxen'; 10 | import delay from 'delay'; 11 | import {render, Box, Text} from '../src/index.js'; 12 | import createStdout from './helpers/create-stdout.js'; 13 | 14 | const require = createRequire(import.meta.url); 15 | 16 | // eslint-disable-next-line @typescript-eslint/consistent-type-imports 17 | const {spawn} = require('node-pty') as typeof import('node-pty'); 18 | 19 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); 20 | 21 | const term = (fixture: string, args: string[] = []) => { 22 | let resolve: (value?: unknown) => void; 23 | let reject: (error: Error) => void; 24 | 25 | // eslint-disable-next-line promise/param-names 26 | const exitPromise = new Promise((resolve2, reject2) => { 27 | resolve = resolve2; 28 | reject = reject2; 29 | }); 30 | 31 | const env = { 32 | ...process.env, 33 | // eslint-disable-next-line @typescript-eslint/naming-convention 34 | NODE_NO_WARNINGS: '1', 35 | }; 36 | 37 | const ps = spawn( 38 | 'node', 39 | [ 40 | '--loader=ts-node/esm', 41 | path.join(__dirname, `./fixtures/${fixture}.tsx`), 42 | ...args, 43 | ], 44 | { 45 | name: 'xterm-color', 46 | cols: 100, 47 | cwd: __dirname, 48 | env, 49 | }, 50 | ); 51 | 52 | const result = { 53 | write(input: string) { 54 | ps.write(input); 55 | }, 56 | output: '', 57 | waitForExit: async () => exitPromise, 58 | }; 59 | 60 | ps.onData(data => { 61 | result.output += data; 62 | }); 63 | 64 | ps.onExit(({exitCode}) => { 65 | if (exitCode === 0) { 66 | resolve(); 67 | return; 68 | } 69 | 70 | reject(new Error(`Process exited with non-zero exit code: ${exitCode}`)); 71 | }); 72 | 73 | return result; 74 | }; 75 | 76 | test.serial('do not erase screen', async t => { 77 | const ps = term('erase', ['4']); 78 | await ps.waitForExit(); 79 | t.false(ps.output.includes(ansiEscapes.clearTerminal)); 80 | 81 | for (const letter of ['A', 'B', 'C']) { 82 | t.true(ps.output.includes(letter)); 83 | } 84 | }); 85 | 86 | test.serial( 87 | 'do not erase screen where is taller than viewport', 88 | async t => { 89 | const ps = term('erase-with-static', ['4']); 90 | 91 | await ps.waitForExit(); 92 | t.false(ps.output.includes(ansiEscapes.clearTerminal)); 93 | 94 | for (const letter of ['A', 'B', 'C', 'D', 'E', 'F']) { 95 | t.true(ps.output.includes(letter)); 96 | } 97 | }, 98 | ); 99 | 100 | test.serial('erase screen', async t => { 101 | const ps = term('erase', ['3']); 102 | await ps.waitForExit(); 103 | t.true(ps.output.includes(ansiEscapes.clearTerminal)); 104 | 105 | for (const letter of ['A', 'B', 'C']) { 106 | t.true(ps.output.includes(letter)); 107 | } 108 | }); 109 | 110 | test.serial( 111 | 'erase screen where exists but interactive part is taller than viewport', 112 | async t => { 113 | const ps = term('erase', ['3']); 114 | await ps.waitForExit(); 115 | t.true(ps.output.includes(ansiEscapes.clearTerminal)); 116 | 117 | for (const letter of ['A', 'B', 'C']) { 118 | t.true(ps.output.includes(letter)); 119 | } 120 | }, 121 | ); 122 | 123 | test.serial('erase screen where state changes', async t => { 124 | const ps = term('erase-with-state-change', ['4']); 125 | await ps.waitForExit(); 126 | 127 | const secondFrame = ps.output.split(ansiEscapes.eraseLines(3))[1]; 128 | 129 | for (const letter of ['A', 'B', 'C']) { 130 | t.false(secondFrame?.includes(letter)); 131 | } 132 | }); 133 | 134 | test.serial('erase screen where state changes in small viewport', async t => { 135 | const ps = term('erase-with-state-change', ['3']); 136 | await ps.waitForExit(); 137 | 138 | const frames = ps.output.split(ansiEscapes.clearTerminal); 139 | const lastFrame = frames.at(-1); 140 | 141 | for (const letter of ['A', 'B', 'C']) { 142 | t.false(lastFrame?.includes(letter)); 143 | } 144 | }); 145 | 146 | test.serial('clear output', async t => { 147 | const ps = term('clear'); 148 | await ps.waitForExit(); 149 | 150 | const secondFrame = ps.output.split(ansiEscapes.eraseLines(4))[1]; 151 | 152 | for (const letter of ['A', 'B', 'C']) { 153 | t.false(secondFrame?.includes(letter)); 154 | } 155 | }); 156 | 157 | test.serial( 158 | 'intercept console methods and display result above output', 159 | async t => { 160 | const ps = term('console'); 161 | await ps.waitForExit(); 162 | 163 | const frames = ps.output.split(ansiEscapes.eraseLines(2)).map(line => { 164 | return stripAnsi(line); 165 | }); 166 | 167 | t.deepEqual(frames, [ 168 | 'Hello World\r\n', 169 | 'First log\r\nHello World\r\nSecond log\r\n', 170 | ]); 171 | }, 172 | ); 173 | 174 | test.serial('rerender on resize', async t => { 175 | const stdout = createStdout(10); 176 | 177 | function Test() { 178 | return ( 179 | 180 | Test 181 | 182 | ); 183 | } 184 | 185 | const {unmount} = render(, {stdout}); 186 | 187 | t.is( 188 | stripAnsi((stdout.write as any).firstCall.args[0] as string), 189 | boxen('Test'.padEnd(8), {borderStyle: 'round'}) + '\n', 190 | ); 191 | 192 | t.is(stdout.listeners('resize').length, 1); 193 | 194 | stdout.columns = 8; 195 | stdout.emit('resize'); 196 | await delay(100); 197 | 198 | t.is( 199 | stripAnsi((stdout.write as any).lastCall.args[0] as string), 200 | boxen('Test'.padEnd(6), {borderStyle: 'round'}) + '\n', 201 | ); 202 | 203 | unmount(); 204 | t.is(stdout.listeners('resize').length, 0); 205 | }); 206 | -------------------------------------------------------------------------------- /test/text.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import chalk from 'chalk'; 4 | import {render, Box, Text} from '../src/index.js'; 5 | import {renderToString} from './helpers/render-to-string.js'; 6 | import createStdout from './helpers/create-stdout.js'; 7 | 8 | test(' with undefined children', t => { 9 | const output = renderToString(); 10 | t.is(output, ''); 11 | }); 12 | 13 | test(' with null children', t => { 14 | const output = renderToString({null}); 15 | t.is(output, ''); 16 | }); 17 | 18 | test('text with standard color', t => { 19 | const output = renderToString(Test); 20 | t.is(output, chalk.green('Test')); 21 | }); 22 | 23 | test('text with dimmed color', t => { 24 | const output = renderToString( 25 | 26 | Test 27 | , 28 | ); 29 | 30 | t.is(output, chalk.green.dim('Test')); 31 | }); 32 | 33 | test('text with hex color', t => { 34 | const output = renderToString(Test); 35 | t.is(output, chalk.hex('#FF8800')('Test')); 36 | }); 37 | 38 | test('text with rgb color', t => { 39 | const output = renderToString(Test); 40 | t.is(output, chalk.rgb(255, 136, 0)('Test')); 41 | }); 42 | 43 | test('text with ansi256 color', t => { 44 | const output = renderToString(Test); 45 | t.is(output, chalk.ansi256(194)('Test')); 46 | }); 47 | 48 | test('text with standard background color', t => { 49 | const output = renderToString(Test); 50 | t.is(output, chalk.bgGreen('Test')); 51 | }); 52 | 53 | test('text with hex background color', t => { 54 | const output = renderToString(Test); 55 | t.is(output, chalk.bgHex('#FF8800')('Test')); 56 | }); 57 | 58 | test('text with rgb background color', t => { 59 | const output = renderToString( 60 | Test, 61 | ); 62 | 63 | t.is(output, chalk.bgRgb(255, 136, 0)('Test')); 64 | }); 65 | 66 | test('text with ansi256 background color', t => { 67 | const output = renderToString( 68 | Test, 69 | ); 70 | 71 | t.is(output, chalk.bgAnsi256(194)('Test')); 72 | }); 73 | 74 | test('text with inversion', t => { 75 | const output = renderToString(Test); 76 | t.is(output, chalk.inverse('Test')); 77 | }); 78 | 79 | test('remeasure text when text is changed', t => { 80 | function Test({add}: {readonly add?: boolean}) { 81 | return ( 82 | 83 | {add ? 'abcx' : 'abc'} 84 | 85 | ); 86 | } 87 | 88 | const stdout = createStdout(); 89 | const {rerender} = render(, {stdout, debug: true}); 90 | t.is((stdout.write as any).lastCall.args[0], 'abc'); 91 | 92 | rerender(); 93 | t.is((stdout.write as any).lastCall.args[0], 'abcx'); 94 | }); 95 | 96 | test('remeasure text when text nodes are changed', t => { 97 | function Test({add}: {readonly add?: boolean}) { 98 | return ( 99 | 100 | 101 | abc 102 | {add && x} 103 | 104 | 105 | ); 106 | } 107 | 108 | const stdout = createStdout(); 109 | 110 | const {rerender} = render(, {stdout, debug: true}); 111 | t.is((stdout.write as any).lastCall.args[0], 'abc'); 112 | 113 | rerender(); 114 | t.is((stdout.write as any).lastCall.args[0], 'abcx'); 115 | }); 116 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."] 4 | } 5 | -------------------------------------------------------------------------------- /test/width-height.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import test from 'ava'; 3 | import {Box, Text} from '../src/index.js'; 4 | import {renderToString} from './helpers/render-to-string.js'; 5 | 6 | test('set width', t => { 7 | const output = renderToString( 8 | 9 | 10 | A 11 | 12 | B 13 | , 14 | ); 15 | 16 | t.is(output, 'A B'); 17 | }); 18 | 19 | test('set width in percent', t => { 20 | const output = renderToString( 21 | 22 | 23 | A 24 | 25 | B 26 | , 27 | ); 28 | 29 | t.is(output, 'A B'); 30 | }); 31 | 32 | test('set min width', t => { 33 | const smallerOutput = renderToString( 34 | 35 | 36 | A 37 | 38 | B 39 | , 40 | ); 41 | 42 | t.is(smallerOutput, 'A B'); 43 | 44 | const largerOutput = renderToString( 45 | 46 | 47 | AAAAA 48 | 49 | B 50 | , 51 | ); 52 | 53 | t.is(largerOutput, 'AAAAAB'); 54 | }); 55 | 56 | test.failing('set min width in percent', t => { 57 | const output = renderToString( 58 | 59 | 60 | A 61 | 62 | B 63 | , 64 | ); 65 | 66 | t.is(output, 'A B'); 67 | }); 68 | 69 | test('set height', t => { 70 | const output = renderToString( 71 | 72 | A 73 | B 74 | , 75 | ); 76 | 77 | t.is(output, 'AB\n\n\n'); 78 | }); 79 | 80 | test('set height in percent', t => { 81 | const output = renderToString( 82 | 83 | 84 | A 85 | 86 | B 87 | , 88 | ); 89 | 90 | t.is(output, 'A\n\n\nB\n\n'); 91 | }); 92 | 93 | test('cut text over the set height', t => { 94 | const output = renderToString( 95 | 96 | AAAABBBBCCCC 97 | , 98 | {columns: 4}, 99 | ); 100 | 101 | t.is(output, 'AAAA\nBBBB'); 102 | }); 103 | 104 | test('set min height', t => { 105 | const smallerOutput = renderToString( 106 | 107 | A 108 | , 109 | ); 110 | 111 | t.is(smallerOutput, 'A\n\n\n'); 112 | 113 | const largerOutput = renderToString( 114 | 115 | 116 | A 117 | 118 | , 119 | ); 120 | 121 | t.is(largerOutput, 'A\n\n\n'); 122 | }); 123 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "lib": [ 6 | "DOM", 7 | "DOM.Iterable", 8 | "ES2023" 9 | ], 10 | "sourceMap": true, 11 | "jsx": "react", 12 | "isolatedModules": true 13 | }, 14 | "include": ["src"], 15 | "ts-node": { 16 | "transpileOnly": true, 17 | "files": true, 18 | "experimentalResolver": true, 19 | "experimentalSpecifierResolution": "node" 20 | } 21 | } 22 | --------------------------------------------------------------------------------