Tutorial 48 of 54 · for anyone whose terminal tool takes typed text · Markdown

Type into the app

You'll add tasks from a text box and show a spinner while the list waits to be saved. Both come from npm packages FSR provides, so there is nothing to install. It starts from the app you built in 630.

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.

The packages FSR provides#

A terminal page does not follow npm packages. A few that nearly every Ink tool uses are provided instead, written in TypeScript the way the packages are written, so you import them by their usual names:

ImportWhat you get
ink-text-inputTextInput and UncontrolledTextInput
ink-spinnerSpinner, with the spinners of cli-spinners
@snapfire/fsr/terminalms, stripAnsi, stringWidth and wrapAnsi

TextInput reads keys with useInput like any other component and Spinner steps on a timer like any other component. Another package from npm is refused.

Add the imports and the state#

app/routes/page.tsx
import { useEffect, useState } from "react";
import type { RootProps } from "@generated/client";
import { file, path } from "@snapfire/fsr/host";
import { Box, Spacer, Text, useInput } from "ink";
import Spinner from "ink-spinner";
import TextInput from "ink-text-input";

Newline is no longer used, since the footer becomes a row. Inside Tasks, below selected, add three pieces of state:

app/routes/page.tsx
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);

adding says whether the text box is open, draft is what is typed into it and saving says a change has not been written yet.

Save half a second after a change#

In 630 every change wrote the file at once. This step waits until the changes stop and writes 500 ms after the last one, which is what the spinner shows. Below the state, add the effect that does it:

app/routes/page.tsx
useEffect(() => {
  if (!saving) return;
  const timer = setTimeout(() => {
    save(where, tasks);
    setSaving(false);
  }, 500);
  return () => clearTimeout(timer);
}, [tasks]);

The effect runs after each render that changed tasks. Its cleanup clears the timer before the next run, so a second change inside the half second starts the wait again. On the first render saving is false, so opening the app writes nothing.

Open the text box#

Change the handler. While the box is open, the list's keys stay quiet and Esc closes it. a opens it. Space now marks the list as needing a save instead of writing at once:

app/routes/page.tsx
useInput((input, key) => {
  if (adding) {
    if (key.escape) setAdding(false);
    return;
  }
  if (key.upArrow && selected > 0) setSelected(selected - 1);
  if (key.downArrow && selected < shown.length - 1) setSelected(selected + 1);
  if (input === "a") setAdding(true);
  if (input === " ") {
    setTasks(tasks.map((task, index) => (index === selected ? { ...task, done: !task.done } : task)));
    setSaving(true);
  }
});

const add = (title: string) => {
  setAdding(false);
  setDraft("");
  if (title.trim() === "") return;
  setTasks([...tasks, { title: title.trim(), done: false }]);
  setSaving(true);
};

TextInput handles the typing itself: characters, backspace and the cursor. onChange hands you the new text and onSubmit runs on Enter with it.

Draw the box and the spinner#

Replace the footer Text at the bottom of the outer Box with the box and a footer row:

app/routes/page.tsx
{adding ? (
  <Box>
    <Text color="cyan">new: </Text>
    <TextInput value={draft} onChange={setDraft} onSubmit={add} placeholder="what needs doing" />
  </Box>
) : null}
<Box>
  <Text dimColor>{adding ? "Enter adds, Esc cancels" : "a adds, space marks one done"}</Text>
  <Spacer />
  {saving ? (
    <Text color="yellow">
      <Spinner type="dots" /> saving
    </Text>
  ) : (
    <Text dimColor>saved</Text>
  )}
</Box>

type picks a spinner from cli-spinners by name, dots or line for example. The spinner steps at the interval cli-spinners gives it.

Run it#

Start with no saved list:

$ rm -rf ~/.tasks
$ fsr dev app --terminal

Press a. The box opens with its placeholder:

╭──────────────────────────────────────────╮
│ Tasks tasks.json                  1 open │
│                                          │
│ > Write the first task                   │
│                                          │
│ new: what needs doing                    │
│ Enter adds, Esc cancels            saved │
╰──────────────────────────────────────────╯

Type Ship it. The space goes into the text and does not mark a task done, because the list's handler returns while the box is open. Press Enter. The task is added and the spinner runs:

╭──────────────────────────────────────────╮
│ Tasks tasks.json                  2 open │
│                                          │
│ > Write the first task                   │
│   Ship it                                │
│                                          │
│ a adds, space marks one done    ⠋ saving │
╰──────────────────────────────────────────╯

Half a second later it reads saved and the file has both tasks:

$ cat ~/.tasks/tasks.json
[
  {
    "title": "Write the first task",
    "done": false
  },
  {
    "title": "Ship it",
    "done": false
  }
]

Next up: 650. Move focus between panes.