/** * pi-typing — typing speed test. /typing [code|quotes] * Measures WPM and accuracy. Persistent best scores. */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; const SAVE_DIR = join(homedir(), ".pi", "typing"); const SAVE_FILE = join(SAVE_DIR, "save.json"); const RST = "\x1b[0m"; const BOLD = "\x1b[1m"; const DIM = "\x1b[2m"; const GREEN = "\x1b[32m"; const RED = "\x1b[31m"; const YELLOW = "\x1b[33m"; const CYAN = "\x1b[36m"; const BG_RED = "\x1b[41;37m"; const UNDERLINE = "\x1b[4m"; const CODE_SNIPPETS = [ `function fibonacci(n) { if (n <= 1) return n; return fibonacci(n - 1) + fibonacci(n - 2); }`, `const sum = arr.reduce((acc, val) => acc + val, 0); console.log(sum);`, `for (let i = 0; i < items.length; i++) { if (items[i].active) result.push(items[i]); }`, `async function fetchData(url) { const res = await fetch(url); return res.json(); }`, `const map = new Map(); for (const [key, val] of entries) { map.set(key, val); }`, `class Node { constructor(val) { this.val = val; this.next = null; } }`, `const sorted = [...array].sort((a, b) => a.name.localeCompare(b.name));`, `export default function handler(req, res) { res.status(200).json({ ok: true }); }`, `const unique = [...new Set(items.map(i => i.category))];`, `try { const data = JSON.parse(raw); process(data); } catch (e) { console.error(e); }`, `const debounce = (fn, ms) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };`, `if (cache.has(key)) return cache.get(key); const val = compute(key); cache.set(key, val);`, `const [state, setState] = useState(0); useEffect(() => { fetchItems(); }, []);`, `app.get("/api/users/:id", async (req, res) => { const user = await db.find(req.params.id); res.json(user); });`, `const pipeline = data.filter(x => x.active).map(x => x.value).reduce((a, b) => a + b, 0);`, ]; const QUOTES = [ `The best way to predict the future is to invent it.`, `Simplicity is the ultimate sophistication.`, `Talk is cheap. Show me the code.`, `First, solve the problem. Then, write the code.`, `Code is like humor. When you have to explain it, it is bad.`, `Any fool can write code that a computer can understand. Good programmers write code that humans can understand.`, `The most dangerous phrase in the language is: we have always done it this way.`, `Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away.`, `Make it work, make it right, make it fast.`, `Programs must be written for people to read, and only incidentally for machines to execute.`, `Debugging is twice as hard as writing the code in the first place.`, `The only way to learn a new programming language is by writing programs in it.`, `Complexity is a failure mode.`, `The best infrastructure is the infrastructure you do not have to think about.`, `Delete the code. Git remembers.`, ]; interface SaveData { bestWpm: number; bestAccuracy: number; totalTests: number; avgWpm: number } function loadSave(): SaveData { try { if (existsSync(SAVE_FILE)) return JSON.parse(readFileSync(SAVE_FILE, "utf-8")); } catch {} return { bestWpm: 0, bestAccuracy: 0, totalTests: 0, avgWpm: 0 }; } function saveSave(d: SaveData) { try { mkdirSync(SAVE_DIR, { recursive: true }); writeFileSync(SAVE_FILE, JSON.stringify(d)); } catch {} } export default function piTyping(pi: ExtensionAPI) { pi.registerCommand("typing", { description: "Typing speed test. /typing [code|quotes]", aliases: ["/typetest", "/wpm"], handler: async (args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("Typing test requires interactive mode", "error"); return; } const mode = args.trim().toLowerCase(); const pool = mode === "quotes" ? QUOTES : mode === "code" ? CODE_SNIPPETS : [...CODE_SNIPPETS, ...QUOTES]; const target = pool[Math.floor(Math.random() * pool.length)]; let typed = ""; let startTime = 0; let finished = false; let wpm = 0; let accuracy = 0; let elapsedMs = 0; const save = loadSave(); await ctx.ui.custom((tui: any, _t: any, _k: any, done: (v: undefined) => void) => { let version = 0; let timer: ReturnType | null = null; function startTimer() { if (!timer) timer = setInterval(() => { version++; tui.requestRender(); }, 200); } function handleInput(data: string) { if (data === "\x03") { if (timer) clearInterval(timer); done(undefined); return; } if (finished) { if (data === "r" || data === "R") { // Restart with new text if (timer) clearInterval(timer); timer = null; done(undefined); return; } if (data === "q" || data === "Q") { if (timer) clearInterval(timer); done(undefined); return; } return; } // Backspace if (data === "\x7f" || data === "\b") { if (typed.length > 0) typed = typed.slice(0, -1); version++; tui.requestRender(); return; } // Regular character if (data.length === 1 && data >= " ") { if (!startTime) { startTime = Date.now(); startTimer(); } typed += data; // Check if done if (typed.length >= target.length) { finished = true; elapsedMs = Date.now() - startTime; if (timer) clearInterval(timer); const minutes = Math.max(elapsedMs / 60000, 0.001); // floor at ~60ms to prevent Infinity const words = target.length / 5; // standard: 5 chars = 1 word wpm = Math.round(words / minutes); let correct = 0; for (let i = 0; i < target.length; i++) { if (typed[i] === target[i]) correct++; } accuracy = Math.round((correct / target.length) * 100); // Save save.totalTests++; if (wpm > save.bestWpm) save.bestWpm = wpm; if (accuracy > save.bestAccuracy) save.bestAccuracy = accuracy; save.avgWpm = Math.round(((save.avgWpm * (save.totalTests - 1)) + wpm) / save.totalTests); saveSave(save); } version++; tui.requestRender(); } } function render(width: number): string[] { const lines: string[] = []; const maxW = Math.min(width - 4, 80); lines.push(""); lines.push(` ${BOLD}T Y P I N G T E S T${RST} ${DIM}${mode || "mixed"}${RST}`); lines.push(""); if (finished) { // Results lines.push(` ${BOLD}${wpm >= 60 ? GREEN : wpm >= 40 ? YELLOW : RED}${wpm} WPM${RST} ${accuracy >= 95 ? GREEN : accuracy >= 85 ? YELLOW : RED}${accuracy}% accuracy${RST} ${DIM}${(elapsedMs / 1000).toFixed(1)}s${RST}`); lines.push(""); lines.push(` ${DIM}Best: ${save.bestWpm} WPM | Best Acc: ${save.bestAccuracy}% | Avg: ${save.avgWpm} WPM | Tests: ${save.totalTests}${RST}`); lines.push(""); // Show what was typed with color coding let resultStr = " "; for (let i = 0; i < target.length; i++) { if (typed[i] === target[i]) resultStr += `${GREEN}${target[i]}${RST}`; else resultStr += `${BG_RED}${target[i]}${RST}`; } lines.push(resultStr); lines.push(""); lines.push(` ${DIM}R = new test | Q = quit${RST}`); } else { // Live typing view const elapsed = startTime ? (Date.now() - startTime) : 0; const liveWpm = elapsed > 2000 ? Math.round((typed.length / 5) / (elapsed / 60000)) : 0; lines.push(` ${DIM}WPM: ${liveWpm || "—"} | ${typed.length}/${target.length} chars${RST}`); lines.push(""); // Wrap target text to maxW const wrapped: string[] = []; for (let i = 0; i < target.length; i += maxW) { wrapped.push(target.slice(i, i + maxW)); } let charIdx = 0; for (const line of wrapped) { let display = " "; for (let i = 0; i < line.length; i++) { const gi = charIdx + i; if (gi < typed.length) { if (typed[gi] === target[gi]) display += `${GREEN}${line[i]}${RST}`; else display += `${BG_RED}${line[i]}${RST}`; } else if (gi === typed.length) { display += `${UNDERLINE}${BOLD}${line[i]}${RST}`; } else { display += `${DIM}${line[i]}${RST}`; } } lines.push(display); charIdx += line.length; } lines.push(""); lines.push(` ${DIM}Type the text above | Ctrl+C to quit${RST}`); } lines.push(""); return lines; } return { handleInput, render, invalidate() {}, dispose() { if (timer) clearInterval(timer); }, get version() { return version; } }; }); }, }); }