Ink interactive CLI components tested with ink-testing-library: working versions (tested 2026-10-15)
Build an interactive Ink 8 (React) terminal checklist with useInput and a timer-driven countdown, and test frames, arrow-key and space input, submit callbacks and effects with ink-testing-library.
passed4 passed · 0 failed2026-10-15 20:09 UTC- Status
- passed
- Last passed
- 2026-10-15 20:09 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38799498972
The code
Exactly the files that passed. Your agent can pull them with get_recipe("ink-testing-library-cli") (install), or see them on GitHub.
{
"name": "ink-testing-library-cli",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"ink": "8.0.0",
"react": "19.3.1"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15",
"ink-testing-library": "4.0.0",
"@types/react": "19.3.0"
}
}
import { useState, useEffect } from "react";
import { Box, Text, useInput, useApp } from "ink";
export type Task = { id: string; title: string };
/** Interactive checklist: arrows move, space toggles, enter submits, q quits. */
export function Checklist({ tasks, onSubmit }: { tasks: Task[]; onSubmit: (done: string[]) => void }) {
const [cursor, setCursor] = useState(0);
const [done, setDone] = useState<Set<string>>(new Set());
const { exit } = useApp();
useInput((input, key) => {
if (key.downArrow) setCursor((c) => Math.min(c + 1, tasks.length - 1));
if (key.upArrow) setCursor((c) => Math.max(c - 1, 0));
if (input === " ") {
const id = tasks[cursor].id;
setDone((d) => { const n = new Set(d); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
if (key.return) onSubmit(tasks.filter((t) => done.has(t.id)).map((t) => t.id));
if (input === "q") exit();
});
return (
<Box flexDirection="column">
<Text bold>Pre-shift checklist ({done.size}/{tasks.length})</Text>
{tasks.map((t, i) => (
<Text key={t.id} color={i === cursor ? "cyan" : undefined}>
{i === cursor ? ">" : " "} [{done.has(t.id) ? "x" : " "}] {t.title}
</Text>
))}
</Box>
);
}
export function Countdown({ from, onDone }: { from: number; onDone: () => void }) {
const [n, setN] = useState(from);
useEffect(() => {
if (n === 0) { onDone(); return; }
const t = setTimeout(() => setN(n - 1), 10);
return () => clearTimeout(t);
}, [n]);
return <Text>{n === 0 ? "Go!" : `Starting in ${n}`}</Text>;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { createElement } from "react";
import { render } from "ink-testing-library";
import { Checklist, Countdown } from "../src/App.tsx";
const tasks = [{ id: "ppe", title: "PPE on" }, { id: "gas", title: "Gas test" }, { id: "permit", title: "Permit signed" }];
const tick = () => new Promise((r) => setTimeout(r, 30));
const ARROW_DOWN = "\u001B[B";
// Strip ANSI in case color is forced (CI sets FORCE_COLOR on some runners)
const plain = (s = "") => s.replace(/\u001B\[[0-9;]*m/g, "");
test("initial frame", () => {
const { lastFrame, unmount } = render(createElement(Checklist, { tasks, onSubmit: () => {} }));
assert.equal(plain(lastFrame()), "Pre-shift checklist (0/3)\n> [ ] PPE on\n [ ] Gas test\n [ ] Permit signed");
unmount();
});
test("keyboard toggles and moves the cursor", async () => {
const { lastFrame, stdin, unmount } = render(createElement(Checklist, { tasks, onSubmit: () => {} }));
await tick();
stdin.write(" ");
await tick();
stdin.write(ARROW_DOWN);
await tick();
stdin.write(ARROW_DOWN);
await tick();
stdin.write(" ");
await tick();
assert.equal(plain(lastFrame()), "Pre-shift checklist (2/3)\n [x] PPE on\n [ ] Gas test\n> [x] Permit signed");
unmount();
});
test("enter submits selected ids", async () => {
let got: string[] | null = null;
const { stdin, unmount } = render(createElement(Checklist, { tasks, onSubmit: (d: string[]) => (got = d) }));
await tick();
stdin.write(ARROW_DOWN);
await tick();
stdin.write(" ");
await tick();
stdin.write("\r");
await tick();
assert.deepEqual(got, ["gas"]);
unmount();
});
test("effects and timers re-render frames", async () => {
let finished = false;
const { lastFrame, frames, unmount } = render(createElement(Countdown, { from: 3, onDone: () => (finished = true) }));
assert.equal(plain(lastFrame()), "Starting in 3");
for (let i = 0; i < 20 && !finished; i++) await tick();
assert.equal(plain(lastFrame()), "Go!");
assert.ok(finished);
assert.ok(frames.map(plain).includes("Starting in 1"));
unmount();
});
{ "compilerOptions": { "jsx": "react-jsx", "module": "esnext", "moduleResolution": "bundler", "target": "es2022", "strict": true } }
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
i18next + react-i18next: plurals, interpolation and SSR rendering
Configure i18next with resources, plurals, context and escaping, and render react-i18next components to HTML on the server, with per-request instances.
Jotai atoms with React 19, tested with Testing Library and isolated stores
Jotai 3 primitive, derived, write-only and async atoms driving a React 19 todo UI with Suspense, tested in Vitest on happy-dom with @testing-library/react, user-event and a fresh createStore per test, plus store-only tests without React.
Next.js 16 App Router route handler + Zod validation
Validate JSON bodies and search params in a Next.js App Router route handler with Zod, returning typed 400s; tested by calling the handler with NextRequest.
Next.js 16 server actions + Zod with useActionState-shaped results
A 'use server' form action that validates FormData with Zod and returns field errors in the shape useActionState expects, tested by calling the action directly.
All ink recipesAll ink-testing-library recipesAll react recipes