Tutorial 44 of 54 · for anyone who wants to write a terminal tool in TypeScript · Markdown

Run an Ink counter in the terminal

You'll scaffold an app, write its root page as an Ink component and run it in the terminal with no Node. Then you'll edit it while it runs and keep the count. This is the first step of the terminal track: the same app grows into a task list that reads files and runs git.

Terminal mode is experimental.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Install the terminal build#

Terminal mode is behind the terminal feature of the CLI. Install it with the feature:

$ cargo install snapfire_fsr_cli --features terminal

With the feature, fsr dev --help lists the flag:

$ fsr dev --help
...
      --terminal                   Runs the root page in this terminal, drawn with Ink's elements and stepped by its useInput handler

Scaffold it#

$ fsr new tasks
wrote     tasks/.gitignore
wrote     tasks/config/app.toml
...
wrote     tasks/app/routes/page.loader.ts
wrote     tasks/app/routes/page.tsx
...
next      fsr dev tasks/app

The terminal draws one file: app/routes/page.tsx. The layout, the error page and the styles are for the web and the terminal ignores them. Delete app/routes/page.loader.ts. You'll write your own in 620.

Write the page#

The page is an Ink component. Ink's names come from ink and the state hook from react, the same imports an Ink app under Node uses.

app/routes/page.tsx
import { useState } from "react";
import { Box, Text, useInput } from "ink";

export default function Counter() {
  const [count, setCount] = useState(0);

  useInput((input) => {
    if (input === "+") setCount(count + 1);
    if (input === "-") setCount(count - 1);
    if (input === "r") setCount(0);
  });

  return (
    <Box flexDirection="column" borderStyle="round" paddingX={1} width={36}>
      <Text bold>Count: {count}</Text>
      <Text dimColor>+ adds, - takes away, r resets</Text>
    </Box>
  );
}

Box is a flexbox container and Text is a run of styled text. Every string you draw goes inside a Text. useInput runs its handler on every key with the character typed as input. The handler sets state and the page draws again.

The build lowers this component to data the terminal runs. No JavaScript engine starts and nothing is served.

Run it#

Run it from the project directory:

$ cd tasks
$ fsr dev app --terminal

The page takes over the terminal:

╭──────────────────────────────────╮
│ Count: 0                         │
│ + adds, - takes away, r resets   │
╰──────────────────────────────────╯

Press + three times:

╭──────────────────────────────────╮
│ Count: 3                         │
│ + adds, - takes away, r resets   │
╰──────────────────────────────────╯

Only the cells that changed are written, so the count is the only thing redrawn. Ctrl-C quits and puts your terminal back.

Edit it while it runs#

Leave it running at 3 and color the count. Change the first Text:

app/routes/page.tsx
<Text bold color={count < 0 ? "red" : "green"}>Count: {count}</Text>

Save. The page lowers again and redraws in green, still at 3. The state stays in the terminal across the edit, so you don't lose your place while you work on the layout.

If a save leaves the page in a shape the build cannot lower, the terminal shows the reason in red in place of the page. Try it with something an Ink app under Node would write. Add import os from "node:os"; and a <Text>{os.homedir()}</Text> above the help line, then save. In an 80-column terminal the page is replaced by this:

fsr: routes/page.tsx:17:14: `os` from `node:os`, which is not FSR's
  read files and paths through `file` and `path` from `@snapfire/fsr/host`, which a web host confines to the directories `[files] roots` names; a terminal program reads the machine through `system` from `@snapfire/fsr/terminal` and its screen through Ink's `useStdout` and `useApp`; `fsr migrate` rewrites Node's onto them

A terminal page never imports Node's modules. 630 reads the machine the way the message says. Take the two lines out and save. The page comes back with the count it had.

Next up: 610. Lay out a task list.