ProvenPin Install

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
pinned versions6
ink@8.0.0react@19.3.1@types/node@26.6.5tsx@4.23.15ink-testing-library@4.0.0@types/react@19.3.0
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
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.

package.json
{
  "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"
  }
}
src/App.tsx
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>;
}
test/app.test.ts
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();
});
tsconfig.json
{ "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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.