# 1. Bootstrap the application In this tutorial we will build an interactive command line application, going through all the tools that inkstand provides. In this first step we start from an empty Ink application and add a header, a scrollback, a line editor, and a command router. ## Install inkstand needs Node 22 or later, Ink 7.1, and React 19. `ink` and `react` are peer dependencies, so install them together: ```sh npm install inkstand ink react ``` ## An empty application Ink renders React components to the terminal. We start with a root component that renders an empty column. Save it as `app.tsx` and run it with `npx tsx app.tsx`: ```tsx import { Box, render } from 'ink'; import type { ReactElement } from 'react'; function App(): ReactElement { return ; } render(); ``` Everything we build in this tutorial goes inside this column. ## The header [`Header`](../../src/views/header.tsx) renders the application banner: the name, the version, and an optional tagline. ```tsx import { Header } from 'inkstand'; function App(): ReactElement { return (
); } ``` `Header` is a view, and every inkstand view works the same way: props in, JSX out. The view holds nothing itself, so what appears on screen is exactly what your component passed in. ## The scrollback An interactive application prints blocks of output that pile up above the input, like a shell session. That area is the scrollback, and it is a plain array in your component: ```tsx import { Header, Scrollback, useScrollback } from 'inkstand'; function App(): ReactElement { const { items, push } = useScrollback(
, ); return ( ); } ``` [`useScrollback`](../../src/hooks/use-scrollback.ts) holds the array and returns `push`, which appends a block. A block is any JSX. The first argument seeds the array, so the header is now the first printed block. [`Scrollback`](../../src/views/scrollback.tsx) renders the array through Ink's `Static`. `Static` prints each block once and leaves it in the terminal's own scrollback, above everything Ink keeps repainting. This makes printing cheap, and it means a pushed block should be final: to change what is already printed you repaint the whole terminal, which we cover in a later step. ## The line editor The input line is three parts. The [`LineEditor`](../../src/machines/line-editor.ts) machine holds the line and the caret, the [`useLineEditor`](../../src/hooks/use-line-editor.ts) hook feeds it keystrokes, and the [`Prompt`](../../src/views/prompt.tsx) view renders it. ```tsx import { Text, useApp } from 'ink'; import { Prompt, useLineEditor } from 'inkstand'; function App(): ReactElement { const { exit } = useApp(); const { items, push } = useScrollback(
, ); const { editor } = useLineEditor({ onInterrupt: exit, onSubmit: (line) => push({'> '}{line}), }); return ( ); } render(, { exitOnCtrlC: false }); ``` The application now echoes every submitted line. Run it and try the editing keys: readline-style movement and deletion, and a command history on the arrow keys. | Keys | Effect | |------------------------------|-------------------------------------------------------| | printable characters | Insert at the caret. | | enter | Submit the line and clear the editor. | | backspace, delete | Delete before or at the caret. | | left, right | Move the caret one character. | | ctrl+left, ctrl+right | Move the caret one word. Meta works too. | | home or ctrl+a, end or ctrl+e | Move the caret to the line start or end. | | ctrl+w | Delete the word before the caret. | | ctrl+u, ctrl+k | Delete to the line start, or to the line end. | | up, down | Browse the history. The line you were typing is kept. | | escape | Clear the line. | | ctrl+c | Clear the line. On an empty line, flag the interrupt. | `LineEditor` is a machine: plain TypeScript, immutable, held by your component. `editor.key(input, key)` returns the next editor, and the hook stores it with `useState`. The hook acts on what the keystroke produced: an interrupt calls `onInterrupt`, and a submitted line is trimmed, added to the history, and passed to `onSubmit`. Two details of the wiring: - `render(, { exitOnCtrlC: false })` gives ctrl+c to the editor. It clears the line, and on an empty line the hook calls `onInterrupt`, which we wired to Ink's `exit`. - `Prompt` is controlled, like every view. It renders `editor.value` with the caret at `editor.cursor`, so the machine decides what the prompt shows. ## The command router The application should run commands. A [`Command`](../../src/machines/router.ts) is an object you define: a name, a description, and a `run` function. `run` receives a context, and the context type is yours. It describes what commands can do to the application, and you build the object at the call site. Ours can push output and exit: ```tsx import { type Command, CommandList, createRouter } from 'inkstand'; import type { ReactNode } from 'react'; interface Ctx { push: (node: ReactNode) => void; exit: () => void; } const commands: Command[] = [ { name: '/greet', description: 'Say hello: /greet [name]', run: (ctx, [name]) => ctx.push(Hello {name ?? 'world'}.), }, { name: '/help', description: 'Show the available commands', run: (ctx) => ctx.push(), }, { name: '/exit', description: 'Quit', run: (ctx) => ctx.exit() }, ]; const router = createRouter(commands); ``` [`createRouter`](../../src/machines/router.ts) returns two pure lookups. `match` resolves a submitted line to a command and its arguments, and `suggest` filters the list by a typed prefix, which we will use for completion in a later step. The leading slash is optional in the line, and the rest is split on whitespace into `args`. `/help` and `/exit` are ordinary entries in the list, so you control their names and behavior. [`CommandList`](../../src/views/command-list.tsx) renders the names and descriptions padded to one width, which makes it the help block. Now we route submitted lines instead of echoing them: ```tsx function submit(line: string, ctx: Ctx): void { const hit = router.match(line); if (hit === undefined) { ctx.push(Unknown command "{line}". Type /help.); return; } void hit.command.run(ctx, hit.args); } function App(): ReactElement { const { exit } = useApp(); const { items, push } = useScrollback(
, ); const { editor } = useLineEditor({ onInterrupt: exit, onSubmit: (line) => submit(line, { push, exit }), }); return ( ); } ``` Type `/help`, `/greet Ada`, or an unknown line. The application answers each one with a block in the scrollback. ## Next [Step 2, Completion](02-completion.md) adds a suggestion list under the prompt: the matching commands appear as you type, and tab picks one.