Skip to content

Repository files navigation

mcq-tui — a keyboard-first terminal UI, built with Ink

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.

License: MIT TypeScript Ink React Node Biome Tested with a real PTY Theme: Catppuccin Keybindings: vim PRs welcome

English · 繁體中文

overview

中文 TL;DR — 網站 hema-20261:1 終端機版。Ink (React for CLI) + 真 Cloudflare Access SSO + vim 鍵位 + 滑鼠點擊 + rg/fzf 加持,純 TypeScript,零後端改動,並用 node-pty 開真 PTY 做端到端測試。想學怎麼把 TUI 做到「上得了檯面」,直接看 📚 Wiki 心得


Why this repo exists

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.

✨ Features

  • Vim-style navigationj/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 cloudflared JWT (same email-OTP). Headless service-token / access-token fallbacks for CI.
  • Catppuccin themes — Mocha / Macchiato / Frappé / Latte, cycled with T and persisted. A live Proxy re-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/cols live.
  • A 5-tab question view — 題目 / 詳解 / 個人筆記 / 討論串 / 相似題目, with context-aware y (yank the explanation, the note, or the stem depending on the active pane).
  • rg + fzf search, FSRS spaced repetition, collaborative explanations via $EDITOR with an optimistic lock, threaded comments with @mentions, answer challenges, notifications, and an activity heatmap.
  • Tested through a real PTYnode-pty + headless xterm drive the compiled binary and assert on the rendered screen, covering raw-mode, ANSI, cursor addressing, and resize (how).

📸 Demo

Login flow (boot → Cloudflare Access → email-OTP → in):

login

Responsive — a narrow window collapses the question page into tabs:

responsive

Catppuccin themes (T to cycle, persisted to ~/.mcq-tui/prefs.json):

Mocha (dark) Latte (light)
mocha latte

GIFs are recorded with VHS: make demo records against the offline demo mode (mcq-tui demo, mock data, no login). make demo-test boots the demo in a real PTY and asserts on the screen. 中文:Latte 是亮色終端設計 —— TUI 沿用終端背景,亮色主題請搭配亮色終端(否則飽和橘 accent 在深色底看起來會偏紅,原委見 Wiki)。

🚀 Quick start

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 data

Install 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 uninstall

🔑 Authentication

Hits 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.

⌨️ Keybindings (vim)

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
ae answer
y yank current pane (stem+options / explanation / note)
space reveal answer / bookmark
n / p next / prev question
14 FSRS grade (Again / Hard / Good / Easy)
f / P finish exam / pause

Or click the footer hints and breadcrumb with the mouse.

🎓 Hard-won lessons (the Wiki)

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-Proxy theme.
  • 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 PTYnode-pty + @xterm/headless, asserting on rendered frames, and taming concurrency flakiness.
  • Theming with Catppuccin (and the truecolor trap) — a live-Proxy palette, persisting the flavor, and why a light theme renders red on a dark terminal.
  • Cloudflare Access auth from a CLIcloudflared, the /login app 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.

🏗️ Architecture

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)

🛠️ Tech stack

TypeScript · Ink (React for CLIs) · React · node-pty + @xterm/headless for PTY tests · Biome · VHS for GIFs · cloudflared · ripgrep · fzf.

🧪 Development

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 VHS

🤝 Contributing

Issues and PRs welcome. Run make check before you push. If you learned something while hacking on it, add it to the Wiki.

📄 License

MIT © Hsieh-Ting Lin

About

A keyboard-first terminal UI (TUI) built with Ink + React + TypeScript: vim keybindings, mouse clicks, Cloudflare Access SSO, Catppuccin themes, and real-PTY end-to-end tests.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages