Terminal Tutorials

Each tutorial builds a terminal app from Ink components, run by fsr dev --terminal with no Node.

Terminal apps

  1. 600Run an Ink counter in the terminalYou'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.app/routes/page.tsximport { 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); }); return ( <Box borderStyle="round" paddingX={1}> <Text bold>Count: {count}</Text> </Box> ); }Tutorial
  2. 610Lay out a task listYou'll turn the counter into a task list with a header, a selected row you move with the arrow keys and a footer of keys, all laid out with Ink's flexbox props. It starts from the app you built in 600.app/routes/page.tsx<Box> <Text bold>Tasks</Text> <Spacer /> <Text color={open === 0 ? "green" : "yellow"}>{open} open</Text> </Box>Tutorial
  3. 620Read the command lineYou'll pass flags to the task list after --, read them in a loader and hand them to the page as props. The loader runs once, when the terminal opens. It starts from the list you built in 610.app/routes/page.loader.tsimport type { Ctx } from "@snapfire/fsr/host"; export async function load({ params }: Ctx<"/">) { return { name: params.file ?? "tasks.json", limit: Number(params.limit ?? "10"), }; }Tutorial
  4. 630Save to a fileYou'll read the task list from a JSON file under your home directory and write it back on every change. A missing file starts the list with a default. It starts from the app you built in 620.app/routes/page.loader.tsfunction read(where: string): Task[] { try { return JSON.parse(file.read(where)); } catch (error) { if (error instanceof Error && error.code === "ENOENT") return STARTER; throw error; } }Tutorial
  5. 640Type into the appYou'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.app/routes/page.tsximport Spinner from "ink-spinner"; import TextInput from "ink-text-input"; <TextInput value={draft} onChange={setDraft} onSubmit={add} placeholder="what needs doing" />Tutorial
  6. 650Move focus between panesYou'll split the task list into a list pane and a detail pane, move between them with Tab and Shift+Tab and give each pane its own keys. It starts from the app you built in 640.app/routes/page.tsxconst { isFocused } = useFocus({ id: "list", autoFocus: true }); useInput( (input, key) => { if (key.upArrow) onMove(-1); }, { isActive: isFocused && active }, );Tutorial
  7. 660Run git and show its outputYou'll run git log --oneline from an effect, collect what it prints and show it in a pane. A key runs it again. It starts from the app you built in 650.app/routes/page.tsxuseEffect(() => { let read = ""; const git = system.spawn("git", ["log", "--oneline", "-5"]); git.stdout.on("data", (chunk) => { read = read + chunk; }); git.on("close", (code) => { if (code === 0) setLines(read.trim().split("\n")); }); }, [runs]);Tutorial
  8. 670Keep timeYou'll time a task with a clock a mount effect starts, quit with an exit code a script can test and lay the panes out again when the terminal is resized. It starts from the app you built in 660.app/routes/page.tsxuseEffect(() => { const tick = setInterval(() => setNow((n) => n + 1), 1000); return () => clearInterval(tick); }, []);Tutorial
  9. 680Bring an Ink tool overYou'll move a small Ink notes browser into FSR with fsr migrate. It rewrites Node's modules, gray-matter and pino onto FSR's members and turns the props render passed into a loader. The tutorials before this one built a terminal app from scratch. This one starts from a tool that already runs under Node.app/routes/page.loader.tsimport type { Ctx } from "@snapfire/fsr/host"; import { path } from "@snapfire/fsr/host"; import { system } from "@snapfire/fsr/terminal"; export async function load({ params }: Ctx<"/">) { const dir = system.env("NOTES_DIR") ?? path.join(system.home(), "notes"); return { dir }; }Tutorial