A production-grade TUI (terminal user interface) in TypeScript — vim keybindings, mouse clicks, Cloudflare Access SSO, Catppuccin themes, responsive layout, and end-to-end tests that drive a real pseudo-terminal. It's a 1:1 terminal client for the hema-2026 medical-board MCQ study app, and a worked example of how to build a serious Ink app.
English · 繁體中文
中文 TL;DR — 網站
hema-2026的 1:1 終端機版。Ink (React for CLI) + 真 Cloudflare Access SSO + vim 鍵位 + 滑鼠點擊 +rg/fzf加持,純 TypeScript,零後端改動,並用 node-pty 開真 PTY 做端到端測試。想學怎麼把 TUI 做到「上得了檯面」,直接看 📚 Wiki 心得。
Most Ink examples stop at "hello world with a spinner." Real terminal apps have to answer harder questions: How do you get mouse clicks when Ink has no mouse layer? How do you test raw-mode input, ANSI rendering, and resize? How do you do SSO from a CLI? Why does a truecolor theme suddenly render red in someone's terminal?
This repo answers all of those the hard way — by shipping them — and the 📚 Wiki distills each into a standalone lesson. If you're here to learn how to build a TUI, start there.
- Vim-style navigation —
j/k/h/l,gg/G,Ctrl-d/u, a:command palette with fuzzy completion, and/quick-jump (/114-001) or full-text search. - Mouse support — click the footer hints (前一題 / 下一題 / 看答案 / 複製 / 返回) and the breadcrumb to navigate. Ink ships no mouse layer; we enable SGR tracking and decode the escape sequences ourselves (how).
- Real Cloudflare Access SSO — authenticates against the same Worker as the website using a
cloudflaredJWT (same email-OTP). Headless service-token / access-token fallbacks for CI. - Catppuccin themes — Mocha / Macchiato / Frappé / Latte, cycled with
Tand persisted. A liveProxyre-colors the whole tree without threading context (how). - Responsive ("RWD for the terminal") — panes collapse into a tab strip on narrow windows; list windows, truncation, and the help overlay all react to
rows/colslive. - A 5-tab question view — 題目 / 詳解 / 個人筆記 / 討論串 / 相似題目, with context-aware
y(yank the explanation, the note, or the stem depending on the active pane). rg+fzfsearch, FSRS spaced repetition, collaborative explanations via$EDITORwith an optimistic lock, threaded comments with@mentions, answer challenges, notifications, and an activity heatmap.- Tested through a real PTY —
node-pty+ headlessxtermdrive the compiled binary and assert on the rendered screen, covering raw-mode, ANSI, cursor addressing, and resize (how).
Login flow (boot → Cloudflare Access → email-OTP → in):
Responsive — a narrow window collapses the question page into tabs:
Catppuccin themes (T to cycle, persisted to ~/.mcq-tui/prefs.json):
| Mocha (dark) | Latte (light) |
|---|---|
![]() |
![]() |
GIFs are recorded with VHS:
make demorecords against the offline demo mode (mcq-tui demo, mock data, no login).make demo-testboots the demo in a real PTY and asserts on the screen. 中文:Latte 是亮色終端設計 —— TUI 沿用終端背景,亮色主題請搭配亮色終端(否則飽和橘 accent 在深色底看起來會偏紅,原委見 Wiki)。
make setup # pnpm install + verify cloudflared / rg / fzf
make login # Cloudflare Access SSO (same email-OTP as the website)
make dev # run the TUI (tsx, no watch — Ctrl-C exits cleanly)
# try it with zero backend / login:
mcq-tui demo # offline demo mode with deterministic mock dataInstall a launcher onto your PATH:
make install # build + drop a `mcq-tui` launcher in ~/.local/bin
mcq-tui # run it from anywhere
make install BINDIR=/usr/local/bin # custom location
make uninstallHits the same Worker; the first matching credential wins:
| # | Env | Header sent | Use |
|---|---|---|---|
| 1 | MCQ_DEV_EMAIL |
X-Dev-Email |
local wrangler dev |
| 2 | MCQ_ACCESS_TOKEN |
Cf-Access-Jwt-Assertion |
headless: a pre-minted CF Access JWT (e.g. cloudflared access token piped into CI) |
| 3 | MCQ_CF_ACCESS_CLIENT_ID + MCQ_CF_ACCESS_CLIENT_SECRET |
CF-Access-Client-Id/Secret |
headless: a Cloudflare Access service token |
| 4 | (none) | Cf-Access-Jwt-Assertion |
interactive: cloudflared SSO (default — same email-OTP as the website) |
Interactive login prompts for your email in the terminal, then hands off to cloudflared access login. On a 401 the token is silently refreshed and the request retried once — we never spawn an interactive browser login mid-session (it would seize the terminal from Ink). See the Cloudflare Access from a CLI lesson.
| Key | Action |
|---|---|
j/k ↑/↓ |
move |
h/l |
prev/next page · cycle panes |
gg / G |
top / bottom |
Ctrl-d / Ctrl-u |
half-page scroll |
Enter |
select / submit |
Esc / q |
back |
: |
command palette (:review :exam :q 114-001 :search anemia) |
/ |
jump-by-id (/114-001) or search |
? |
keybinding help overlay |
r / T |
reload · cycle theme |
a–e |
answer |
y |
yank current pane (stem+options / explanation / note) |
space |
reveal answer / bookmark |
n / p |
next / prev question |
1–4 |
FSRS grade (Again / Hard / Good / Easy) |
f / P |
finish exam / pause |
Or click the footer hints and breadcrumb with the mouse.
The whole point of open-sourcing this is the 📚 Wiki — each page is a self-contained lesson learned by shipping, not theorizing:
- Building a TUI with Ink — layout that reads like the terminal, a stack router, a single keymap source of truth, contexts vs. a live-
Proxytheme. - Mouse support in Ink — Ink has no mouse layer; enable SGR tracking, and why you don't need to hijack stdin (Ink strips the leading ESC, so mouse bytes arrive intact through
useInput). - Testing a TUI with a real PTY —
node-pty+@xterm/headless, asserting on rendered frames, and taming concurrency flakiness. - Theming with Catppuccin (and the truecolor trap) — a live-
Proxypalette, persisting the flavor, and why a light theme renders red on a dark terminal. - Cloudflare Access auth from a CLI —
cloudflared, the/loginapp path gotcha, 401-refresh-retry, and never seizing the terminal. - Responsive terminal layout — sizing off
rows/cols, collapsing panes into tabs, and keeping the footer from being pushed off-screen. - Gotchas & hard-won bugs — a running list: Ctrl-C that won't quit,
ELIFECYCLE 130, stale-value input handlers, case-sensitive option keys, FSRS wanting a rating name not a number, and more.
src/
cli.tsx entry (login / pull / render App)
app.tsx AuthGate + layout + global keys + MouseLayer
router.tsx stack router (vim :cmd navigation)
keymap.ts single source of truth for keybindings
auth/ cloudflared SSO (injects Cf-Access-Jwt-Assertion)
api/ typed client + endpoints (mirror the worker routes)
lib/ tiptap (rich-text↔md) · mouse · proc (fzf/rg/$EDITOR) · cache · config
components/ KeyBar · StatusBar · MouseLayer · HelpOverlay · ScrollBox · SelectList …
screens/ one per website route
hooks/ useAsync · useTerminalSize · useListNav
test/
*.test.ts unit + ink-testing-library
pty/ real-PTY end-to-end (node-pty + @xterm/headless)
TypeScript · Ink (React for CLIs) · React · node-pty + @xterm/headless for PTY tests · Biome · VHS for GIFs · cloudflared · ripgrep · fzf.
make check # typecheck + lint + unit tests + real-PTY tests (must be green to commit)
make test # node --test (unit + ink-testing-library)
make pty-test # real-PTY feature tests
make demo # re-record docs/demo/*.gif via VHSIssues and PRs welcome. Run make check before you push. If you learned something while hacking on it, add it to the Wiki.
MIT © Hsieh-Ting Lin




