How I built a terminal-native build status dashboard with Ink and React for our branded mobile app pipeline, plus the gotchas you only hit at the edges.
It was a Wednesday afternoon. The branded-mobile-app pipeline at the creator economy platform I worked at was backed up, our internal Sidekiq panel was buffering, I had GitHub Actions in one tab, App Store Connect in another, our Slack #bma-releases channel in a third. I was alt-tabbing every ten seconds and I’d just spilled tea on my desk. I closed two of those tabs and started writing a terminal dashboard.
The result was about 400 lines of TypeScript. I’ve been running it daily for a year. Honestly, it’s the most-used personal tool I’ve ever shipped.
So here’s the pitch. Ink renders React in your terminal. Same hooks, same component model, same useEffect. If you’ve built a React app you can build a CLI dashboard. The output happens to be text frames instead of DOM, but the rest is identical. And for the kind of senior engineer who already lives in tmux, a dashboard you can launch with three keystrokes is worth real money.
Two reasons. First, latency. My terminal is always open. A browser tab isn’t. Pulling up a Datadog dashboard for “did my Apple submission go through” takes me ten seconds of context-switching every time. A CLI command takes one.
Second, the build status data I cared about was scattered across at least four systems, GitHub Actions, our internal BMA queue, App Store Connect, and Play Console. A terminal dashboard is a great surface for stitching small pieces of state together. The thing has zero auth ceremony (it reads my local creds), zero ops cost, no one else needs to host it, and it has a fast escape hatch when something looks off, just hit q and drop into a real shell.
If your team isn’t already in the terminal, ship a web dashboard. I’m not going to pretend Ink is for everyone. But if you are, this is the highest-leverage weekend tool you can build.
Nothing exotic. TypeScript, Ink, a couple of Ink ecosystem packages, and tsx for dev runs. Here’s the project skeleton.
{
"name": "bma-dash",
"version": "0.4.2",
"type": "module",
"bin": {
"bma-dash": "./dist/cli.js"
},
"scripts": {
"dev": "tsx watch bin/cli.tsx",
"build": "tsup bin/cli.tsx --format esm --target node20 --clean --shims",
"start": "node dist/cli.js"
},
"dependencies": {
"@octokit/rest": "^20.1.1",
"ink": "^5.0.1",
"ink-spinner": "^5.0.0",
"ink-table": "^3.0.0",
"ink-text-input": "^5.0.1",
"react": "^18.3.1"
},
"devDependencies": {
"tsup": "^8.2.4",
"tsx": "^4.16.2",
"typescript": "^5.5.4"
}
}
The entry file is just a React mount.
#!/usr/bin/env node
import { render } from "ink";
import React from "react";
import { App } from "../src/App.js";
const { waitUntilExit } = render(<App />, {
exitOnCtrlC: true,
});
waitUntilExit().catch((err) => {
console.error(err);
process.exit(1);
});
That shebang matters when you npm publish. Without it, your binary won’t be executable on a fresh install. I forgot it the first time. Spent twenty minutes debugging “command not found” before catching it.
The core of the dashboard is a hook that polls GitHub Actions plus our internal BMA queue every few seconds and merges the two views. The interesting part isn’t the polling, it’s the error handling and the backoff. A polling loop in a CLI is one network blip away from being a denial-of-service against your own API.
import { useEffect, useState } from "react";
import { Octokit } from "@octokit/rest";
type RunStatus = "queued" | "in_progress" | "success" | "failure";
export type BuildRun = {
id: string;
app: string;
status: RunStatus;
startedAt: string;
appleState?: "submitted" | "in_review" | "approved" | "rejected";
};
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
export function useBuildRuns(intervalMs = 4000) {
const [runs, setRuns] = useState<BuildRun[]>([]);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
let attempt = 0;
const controller = new AbortController();
async function poll() {
try {
const [actions, queue] = await Promise.all([
octokit.actions.listWorkflowRunsForRepo({
owner: "acme",
repo: "bma-pipeline",
per_page: 10,
// @ts-expect-error octokit types lag
request: { signal: controller.signal },
}),
fetch("http://localhost:7777/bma/queue", {
signal: controller.signal,
}).then((r) => r.json()),
]);
if (cancelled) return;
setRuns(mergeRuns(actions.data.workflow_runs, queue.items));
setError(null);
attempt = 0;
} catch (err) {
if (cancelled || (err as Error).name === "AbortError") return;
setError(err as Error);
attempt += 1;
} finally {
if (!cancelled) {
const base = Math.min(intervalMs * 2 ** attempt, 30_000);
const jitter = base * (0.5 + Math.random());
setLoading(false);
setTimeout(poll, jitter);
}
}
}
poll();
return () => {
cancelled = true;
controller.abort();
};
}, [intervalMs]);
return { runs, error, loading };
}
A couple of things buried in there. The backoff is jittered exponential, capped at 30 seconds. Backoff lives on the client.
The other thing is that appleState column. That came directly out of a different incident. The branded-mobile-app pipeline we were running had a stretch where about 270 customer apps got stuck in “Waiting for Review” on App Store Connect because Apple silently throttled our submission endpoint, returning 200 OK with a body that looked normal. Our pipeline thought everything had shipped. Support had 80-plus tickets in by 2 p.m. Pacific. We’d extended auto-retry on “stuck” state to fix it, which made things much worse, duplicate submissions, conflicting metadata, three days of slipped releases. The real fix was to read back the submission state from a separate GET, never trust the POST response. So my dashboard’s “Apple state” column is a separate read from Connect’s resource API, not from our own DB. Trust the upstream, not the cache of it.
For filter and search, ink-text-input and useInput are enough. No Redux. A CLI dashboard with global state management is a smell.
import { Box, Text, useInput } from "ink";
import TextInput from "ink-text-input";
import { useState } from "react";
import { RunsTable } from "./RunsTable.js";
import { useBuildRuns } from "./useBuildRuns.js";
export function App() {
const { runs, error, loading } = useBuildRuns();
const [filter, setFilter] = useState("");
const [mode, setMode] = useState<"view" | "filter">("view");
useInput((input, key) => {
if (key.escape) {
setMode("view");
setFilter("");
}
if (input === "/" && mode === "view") setMode("filter");
if (input === "q" && mode === "view") process.exit(0);
});
const visible = filter
? runs.filter((r) => r.app.toLowerCase().includes(filter.toLowerCase()))
: runs;
return (
<Box flexDirection="column" padding={1}>
<Text bold>bma-dash {error ? <Text color="red"> (offline)</Text> : null}</Text>
{mode === "filter" ? (
<Box>
<Text>filter: </Text>
<TextInput value={filter} onChange={setFilter} onSubmit={() => setMode("view")} />
</Box>
) : (
<Text dimColor>press / to filter, q to quit</Text>
)}
<RunsTable runs={visible} loading={loading} />
</Box>
);
}
Keyboard handling is where Ink earns its weight versus, say, a TUI library in another language. The mental model is identical to a React keyboard handler on the web. useInput gives you the keys, you set state, the frame redraws. Done.
Two things web React doesn’t prepare you for.
First, the terminal does a full-frame redraw on every state update. For a runs table with 50 rows updating every four seconds, the screen flickers. The fix is the same fix you’d reach for in web React, memoize aggressively. React.memo on the table component, useMemo on the derived shape passed in.
import { Box, Text } from "ink";
import React, { useMemo } from "react";
import type { BuildRun } from "./useBuildRuns.js";
type Props = { runs: BuildRun[]; loading: boolean };
function RunsTableImpl({ runs, loading }: Props) {
const rows = useMemo(
() =>
runs.map((r) => ({
id: r.id,
app: r.app.padEnd(24).slice(0, 24),
status: r.status,
apple: r.appleState ?? "-",
age: humanizeAge(r.startedAt),
})),
[runs],
);
if (loading && rows.length === 0) {
return <Text dimColor>loading runs...</Text>;
}
return (
<Box flexDirection="column" marginTop={1}>
{rows.map((row) => (
<Box key={row.id}>
<Text>{row.app} </Text>
<Text color={colorFor(row.status)}>{row.status.padEnd(12)}</Text>
<Text color={appleColor(row.apple)}>{row.apple.padEnd(10)}</Text>
<Text dimColor>{row.age}</Text>
</Box>
))}
</Box>
);
}
export const RunsTable = React.memo(RunsTableImpl, (prev, next) => {
if (prev.loading !== next.loading) return false;
if (prev.runs.length !== next.runs.length) return false;
for (let i = 0; i < prev.runs.length; i += 1) {
if (prev.runs[i].id !== next.runs[i].id) return false;
if (prev.runs[i].status !== next.runs[i].status) return false;
if (prev.runs[i].appleState !== next.runs[i].appleState) return false;
}
return true;
});
Second gotcha, terminal width. Long app slugs wrap. The fix is boring, pad and slice to a fixed column width. I don’t bother with responsive layouts in a CLI. If you want responsive, you want a web app.
Once it’s working, publishing is the easy part. bin field plus a shebang plus a build step. I use tsup because esbuild is fast enough that I forget the build is even running.
name: publish
on:
push:
tags: ["v*"]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
--provenance is worth turning on. It pins the publish to a specific GitHub Actions run, which is the closest npm gets to “this came from CI, not someone’s laptop.”
I’ll be direct. If you’re building a tool other engineers on your team will use daily, and your team is split half-terminal half-VSCode-and-Notion, ship a web dashboard. A terminal UI is a wall to anyone not already in tmux. Ink shines when the audience is one (you) or a tight group of terminal-native engineers.
The other failure mode is trying to make Ink a real GUI. Don’t. If you find yourself reaching for mouse handling, complex modals, drag and drop, you’ve left the lane Ink is good at. Ship a Next.js page.
Thanks for reading. If you’ve got thoughts, send them my way.