Introduction #

This article is reference handbook for using Ink v3.2.0 (with TypeScript and React) components to create CLI apps. IDEA Ultimate / Webstorm project files are provided.

To get started w/ Ink v3 please check out this introductory article and this advanced article.

โš ๏ธ This isnโ€™t a reference for React, Node.js, TypeScript, or CSS #

Learn more about these topics on developerlife.com:

Resource Notes
Node.js handbook Learn Node.js w/ TypeScript
React Hooks handbook Learn React, Redux, and Testing w/ TypeScript
CSS handbook Learn CSS, grid, flexbox, to implement responsive design

What is Ink #

Ink is a React renderer written in TypeScript using the react-reconciler package. So instead of rendering React components to the DOM Ink actually renders them to a terminal based UI.

With it, you can make CLI apps that look like this:

Ink supplies its own host UI components (eg: <Text>) which has nothing to do w/ react-dom host components that come out of the box (eg: <img>) w/ React.

Ink supplied host UI components are rendered to a terminal based UI by using:

  1. console streams to manipulate the output to the console, and input from the console.
  2. Yoga to implement CSS flexbox layout and positioning. Yoga is written in C++ that allows flexbox layout itself to be implemented in various platforms (like Lithio on Android, Node.js, .Net, etc).
  3. You can take a look at the dependencies that Ink has in its package.json. It relies on packages like: chalk, cli-boxes, cli-cursor, yoga-layout-prebuilt, react-reconciler, react-devtools-core.

Items covered in this article #

There are a wealth of examples that you can find in the ink repo itself. I suggest browsing through all of them to get some sense of how to use all the host components and hooks.

In this article, we will walk through three examples in order to get a sense of how to use the entire API surface of Ink. There are a lot of community contributions in the form of npm packages that are built using ink that we will not cover here. These examples will cover the usage of the following API surface of ink.

  1. Components
    1. Text
    2. Box
    3. Border
    4. Newline
  2. Hooks
    1. useInput
    2. useApp
    3. useStdin
    4. useFocus
    5. useFocusManager

๐Ÿช„ You can find the source code for these examples in the ink-cli-app3 repo. Please clone this repo to run the samples below on your computer.

Then navigate to the ts-scratch/ink-cli-app3/ folder which contains the following.

  1. package.json and npm start scripts. Make sure to run npm i after cloning this repo.
  2. IDEA & VSCode projects. You can open this folder in your favorite IDE.

Example 1 - moving a component with arrow keys #

This small example is a CLI app that allows the user to move a component on the screen the cursor around using arrow up, down, left, and right keys. It shows how we can use the useInput() hook to make this happen. Hereโ€™s the code.

๐Ÿช„ Hereโ€™s the source code for use-input.tsx.

import React, { useState } from "react"
import { Box, render, Text, useApp, useInput } from "ink"
import { _callIfTruthy } from "r3bl-ts-utils"

const UseInputExample = () => {
  const { exit } = useApp()
  const [x, setX] = useState(1)
  const [y, setY] = useState(1)
  useInput((input, key) => {
    _callIfTruthy(input === "q", () => exit())
    _callIfTruthy(key.leftArrow, () => setX(Math.max(1, x - 1)))
    _callIfTruthy(key.rightArrow, () => setX(Math.min(20, x + 1)))
    _callIfTruthy(key.upArrow, () => setY(Math.max(1, y - 1)))
    _callIfTruthy(key.downArrow, () => setY(Math.min(10, y + 1)))
  })

  return (
    <Box flexDirection="column">
      <Text color={"green"}>Use arrow keys to move the X.</Text>
      <Text color={"red"}>Press โ€œqโ€ to exit.</Text>
      <Box height={12} paddingLeft={x} paddingTop={y}>
        <Text color={"blue"}>X</Text>
      </Box>
    </Box>
  )
}

render(<UseInputExample />)

Hereโ€™s the output that it produces.

$ node -r tsm src/examples/use-input.tsx 
Use arrow keys to move the X.
Press โ€œqโ€ to exit.

   X

Hereโ€™s a brief description of the lifecycle of the app.

  1. First it is started from the terminal when the script is executed using the node ... command shown above.
  2. Once the useInput() hook is called the Node.js process is listening for input events on process.stdin. This prevents the Node.js process from exiting once the app is rendered once.
  3. When the user presses up, down, left, or right, this causes the X and Y padding of the app to be changed, which makes it look like the X is moving around the terminal.
  4. When the user presses q or Ctrl+c this will exit the Node.js process. The useApp() hook supplies an exit() function which can be used to do this.

Example 2 - keyboard input & focus manipulation #

This medium size example goes deep into the hooks useFocus() and useFocusManager() to demonstrate how to manage input focus in a CLI app. Not only can Tab and Shift+Tab be used to move keyboard focus from one component to another, but shortcuts are provided that allow giving focus to a given component (by pressing Shift+1 to focus the first component, Shift+2 the second, and Shift+3 the third). A debug component is provided which shows what keys are actually being pressed. Additionally, flexbox is used via Box to organize the components in a sophisticated way.

๐Ÿช„ Hereโ€™s the source code for use-focus.tsx.

import { Box, Newline, render, Text, useApp, useFocus, useFocusManager } from "ink"
import {
  _callIfTrue,
  KeyboardInputHandlerFn,
  makeReactElementFromArray,
  useKeyboard,
  UserInputKeyPress,
} from "r3bl-ts-utils"
import React, { FC } from "react"

//#region Main functional component.
const UseFocusExample: FC = function (): JSX.Element {
  const [keyPress, inRawMode] = useKeyboard(
    onKeyPress.bind({ app: useApp(), focusManager: useFocusManager() })
  )

  return (
    <Box flexDirection="column">
      {keyPress && (
        <Row_Debug
          inRawMode={inRawMode}
          keyPressed={keyPress?.key}
          inputPressed={keyPress?.input}
        />
      )}
      <Row_Instructions />
      <Row_FocusableItems />
    </Box>
  )
}
//#endregion

//#region Keypress handler.
const onKeyPress: KeyboardInputHandlerFn = function (
  this: { app: ReturnType<typeof useApp>; focusManager: ReturnType<typeof useFocusManager> },
  userInputKeyPress: UserInputKeyPress
) {
  const { app, focusManager } = this
  const { exit } = app
  const { focus } = focusManager
  const { input, key } = userInputKeyPress

  _callIfTrue(input === "q", exit)
  _callIfTrue(key === "ctrl" && input === "q", exit)
  _callIfTrue(input === "!", () => focus("1"))
  _callIfTrue(input === "@", () => focus("2"))
  _callIfTrue(input === "#", () => focus("3"))
}
//#endregion

//#region UI.

function Row_Debug(props: {
  inRawMode: boolean
  keyPressed: string | undefined
  inputPressed: string | undefined
}) {
  const { inputPressed, keyPressed, inRawMode } = props
  return inRawMode ? (
    <>
      <Text color={"magenta"}>input: {inputPressed}</Text>
      <Text color={"gray"}>key: {keyPressed}</Text>
    </>
  ) : (
    <Text>keyb disabled</Text>
  )
}

const Row_Instructions: FC = function (): JSX.Element {
  return makeReactElementFromArray(
    [
      ["blue", "Press Tab to focus next element"],
      ["blue", "Shift+Tab to focus previous element"],
      ["blue", "Esc to reset focus."],
      ["green", "Press Shift+<n> to directly focus on 1st through 3rd item."],
      ["red", "To exit, press Ctrl+q, or q"],
    ],
    (item: string[], id: number): JSX.Element => (
      <Text color={item[0]} key={id}>
        {item[1]}
      </Text>
    )
  )
}

const Row_FocusableItems: FC = function (): JSX.Element {
  return (
    <Box padding={1} flexDirection="row" justifyContent={"space-between"}>
      <FocusableItem id="1" label="First" />
      <FocusableItem id="2" label="Second" />
      <FocusableItem id="3" label="Third" />
    </Box>
  )
}

const FocusableItem: FC<{ label: string; id: string }> = function ({ label, id }): JSX.Element {
  const { isFocused } = useFocus({ id })
  return (
    <Text>
      {label}
      {isFocused ? (
        <>
          <Newline />
          <Text color="green">(*)</Text>
        </>
      ) : (
        <>
          <Newline />
          <Text color="gray">n/a</Text>
        </>
      )}
    </Text>
  )
}

//#endregion

render(<UseFocusExample />)

This is what the output looks like when Shift+2 (ie @) is typed to gain focus to the โ€œSecondโ€ component.

$ node -r tsm src/examples/use-focus.tsx
input: @
key: n/a
Press Tab to focus next element
Shift+Tab to focus previous element
Esc to reset focus.
Press Shift+<n> to directly focus on 1st through 3rd item.
To exit, press Ctrl+q, or q

 First                      Second                      Third
 n/a                        (*)                         n/a

Hereโ€™s a brief description of the lifecycle of the app.

  1. First it is started from the terminal when the script is executed using the node ... command shown above.
  2. The main UI is a flexbox container (flexDirection="column") which lays out three components top to bottom.
    1. The first row is Row_Debug component which displays which key is currently pressed if any.
    2. The second row is Row_Instructions component which displays the keyboard shortcuts to use to interact with the CLI app.
    3. The third row is Row_FocusableItems component which is another flexbox container (flexDirection="row" this time) which lays out the focusable Text components left to right.
  3. The custom hook useKeyboard() gets the key that is pressed (the UserInput object). It works hand in hand w/ the FocusableItem component in order to make all of this work.
    1. This custom hook just calls the useInput() hook (just like in the example above). This prevents the Node.js process from exiting once the app is rendered once.
    2. When the user presses keys, this is saved to the state and then the Row_Debug is updated w/ this information.
    3. When Shift+1, Shift+2, Shift+3 is pressed, the const { focus } = useFocusManager() hook is used to directly bring focus to the Text components (via a call to focus(id). As a setup for this to work, when each focusable component is declared (inside Row_FocusableItems) the useFocus({ id }) hook has to be given the same ID that is used to bring focus directly to it.
    4. In FocusableItem, the isFocused boolean from the hook call const { isFocused } = useFocus({ id }) is used to determine whether that component currently has keyboard focus and if so, it renders itself slightly differently.
    5. When the user presses q, Ctrl+q, or Ctrl+c this will exit the Node.js process. The useApp() hook supplies an exit() function which can be used to do this.

Example 3 - full screen app #

This large size example is similar to the medium size one, except that it has a more complex UI. It takes up the full height and width of the terminal that it is started in. And it listens for keyboard input. It also uses a lot of custom hooks. For the UI, it splits the screen into two columns, and adds components to each column. The UI also features a ticking clock that updates every 1 second.

๐Ÿช„ Hereโ€™s the source code for cli.tsx. You can find all the other files that are loaded by cli.tsx in the repo.

You can run the program using the following command npm run start-dev.

The following output is produced.

   โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
   โ”‚  1st column       2nd column                     โ”‚
   โ”‚  Hello           Item 1                          โ”‚
   โ”‚ Stranger         Item 2                          โ”‚
   โ”‚  7:16:58 PM       rows: 19, columns: 58          โ”‚
   โ”‚  keyb enabled                                    โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ”‚                                                  โ”‚
   โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ 

Letโ€™s start w/ the main app.tsx file which is loaded via cli.tsx when the CLI app is run from the terminal. Hereโ€™s the one line that kicks everything off. appFn is a functional React component.

export const appFn: FC<{ name: string }> = ({ name }) => render(runHooks(name))

There are quite a few hooks which are executed (custom ones) that return an object, which is then used by the render function to paint the UI. Hereโ€™s what the runHooks() function looks like.

function runHooks(name: string): LocalVars {
  usePreventProcessExitDuringTesting() // For testing using `npm run start-dev-watch`.
  const ttySize: TTYSize = useTTYSize()
  const time = useClock()
  const inRawMode = _let(useApp(), (it) => {
    const [_, inRawMode] = useKeyboard(onKeyboardFn.bind({ useApp: it }))
    return inRawMode
  })
  return {
    name,
    ttySize,
    time,
    inRawMode,
  }
}
interface LocalVars {
  ttySize: TTYSize
  inRawMode: boolean
  time: number
  name: string
}

Here is a brief overview of what the hooks do.

  1. usePreventProcessExitDuringTesting() is a hook that comes from r3bl-ts-utils. It checks to see if the terminal is in raw mode (which is needed to enable keyboard input). If itโ€™s not in raw mode & keyboard input isnโ€™t possible, it just starts a Timer that doesnโ€™t do anything so that the Node.js process wonโ€™t exit.
  2. useTTYSize() is a hook that comes from r3bl-ts-utils. It provides the width and height of the terminal. This is used by the UI code below in order to take up the full width and height of the terminal. A โ€œresizeโ€ listener is also registered so that if this terminal is resized, the new width and height, will be propagated and the UI re-rendered by React.
  3. useClock() is a hook that comes from r3bl-ts-utils that simply starts a Timer and returns the current time. This time is displayed in the UI. It is updated every second and setState() is used in order to re-render the React UI.
  4. useKeyboard() is a hook that comes from r3bl-ts-utils that simply attaches the given function to handle key input from the terminal. The useApp() hook is provided by Ink, and it allows access to the exit() function which can be used to exit the CLI app. This is useful when you create keyboard shortcuts that allow the user to exit the terminal app (eg: Ctrl+q).
  5. Finally, an object (implementing LocalVars) is returned that is used by the render() function in order to paint the UI. This explicit passing of local state is meant to make it clear that this is a stateless functional component.

๐Ÿช„ There are quite a few hooks & utility classes that are provided by r3bl-ts-utils that are used here (like Timer, useTTYSize(), _let(), etc.). Learn more about this package here.

Hereโ€™s the code that provides the function for keyboard input handling.

/**
 * ๐Ÿช„ This function implements `KeyboardInputHandlerFn` interface.
 *
 * `this` binds it to an object of type OnKeyboardContext. Since this function is a callback that's
 * executed by Ink itself, it can't make any calls to hooks (like `useApp()` which is why re-binding
 * `this` is needed).
 */
function onKeyboardFn(
  this: {
    useApp: ReturnType<typeof useApp>
  },
  keyPress: UserInputKeyPress
) {
  const { useApp } = this

  _callIfTrue(keyPress.toString() === "ctrl+q", useApp.exit)
  _callIfTrue(keyPress.toString() === "q", useApp.exit)
  _callIfTrue(keyPress.toString() === "escape", useApp.exit)
}

And hereโ€™s the function that renders the UI, using the objects in LocalVars that is generated by runHooks().

//#region render().
function render(locals: LocalVars) {
  const { inRawMode, ttySize, time } = locals
  return (
    <Box flexDirection="row" alignSelf={"center"} height={ttySize.rows}>
      <Box
        borderStyle="round"
        borderColor={Style.brandColor}
        flexDirection="row"
        paddingLeft={1}
        paddingRight={1}
        width={Style.appWidth}
      >
        <Box flexDirection="column" flexBasis={Style.column1Width}>
          {renderColumn1(locals)}
          {TextStyle.subHeading(new Date(time).toLocaleTimeString())}
          {inRawMode ? TextStyle.subHeading("keyb enabled") : TextStyle.subHeading("keyb disabled")}
        </Box>
        <Box flexDirection="column" flexGrow={1}>
          {renderColumn2(locals)}
        </Box>
      </Box>
    </Box>
  )
}
//#endregion

//#region UI.
function renderColumn1(locals: LocalVars): JSX.Element {
  const { name } = locals
  return (
    <>
      {TextStyle.heading("1st column")}
      {TextStyle.subHeading("Hello")}
      {TextStyle.emphasis(name)}
    </>
  )
}

function renderColumn2(locals: LocalVars): JSX.Element {
  const { ttySize } = locals
  return (
    <>
      {TextStyle.heading("2nd column")}
      {TextStyle.styleNormal("Item 1")}
      {TextStyle.styleNormal("Item 2")}
      {TextStyle.heading(ttySize.toString())}
    </>
  )
}
//#endregion

Most of this code is flexbox in order to create the 2 column layout that takes up the entire width and height of the terminal in which this app is run.

๐Ÿ‘€ Watch Rust ๐Ÿฆ€ live coding videos on our YouTube Channel.



๐Ÿ“ฆ Install our useful Rust command line apps using cargo install r3bl-cmdr (they are from the r3bl-open-core project):
  • ๐Ÿฑgiti: run interactive git commands with confidence in your terminal
  • ๐Ÿฆœedi: edit Markdown with style in your terminal

giti in action

edi in action

Related Posts