""" vim_terminal_trainer.py – *v2* ================================ **New feature:** you no longer have to press **Return** when you know the command. As soon as the characters you type exactly match a valid answer, the trainer auto‑advances to the next question. This is now implemented with the standard‑library **curses** module, so it should run on any Unix‑like terminal (macOS, Linux, WSL, etc.). Key changes ----------- * Real‑time character capture with `curses` – no blocking `input()` inside the quiz loop. * Timer bar counts down in the top‑right corner. * Still stores a persistent high‑score in `~/.vim_terminal_trainer/score.json`. * Backspace works while answering. Run it like before: ```bash chmod +x vim_terminal_trainer.py ./vim_terminal_trainer.py ``` """ from __future__ import annotations import curses import json import os import random import textwrap import time from pathlib import Path from typing import Dict, List, Tuple DEFAULT_QUESTIONS: List[Dict[str, object]] = [ {"task": "Delete (cut) the current line in Vim", "answers": ["dd"]}, {"task": "Save file and quit Vim (one command)", "answers": [":wq", ":x"]}, {"task": "Quit Vim **without** saving", "answers": [":q!", "ZQ"]}, {"task": "Yank (copy) three lines including the current", "answers": ["3yy"]}, {"task": "Paste yanked text **below** current line", "answers": ["p"]}, {"task": "Move to first non-blank character of the line", "answers": ["^"]}, {"task": "Jump to end of file", "answers": ["G"]}, { "task": "List *all* files, including hidden, one entry per line", "answers": ["ls -a1", "ls -a -1"], }, { "task": "Recursively search for the string ‘TODO’ in *.py files (GNU grep)", "answers": [ "grep -R --include='*.py' 'TODO' .", "grep -R --include=*.py TODO .", ], }, { "task": "Show the last 20 lines of the file `log.txt` *as they grow*", "answers": ["tail -f -n 20 log.txt", "tail -20f log.txt"], }, { "task": "Change word under cursor to UPPERCASE in Vim (normal‑mode, 1 cmd)", "answers": ["gUiw"], }, {"task": "Switch to the previous buffer in Vim", "answers": [":b#", "", "^"]}, { "task": "Undo the last change in Vim (normal-mode, one command)", "answers": ["u"], }, {"task": "Redo the last undone change in Vim", "answers": [""]}, {"task": "Delete (cut) from cursor to end of line in Vim", "answers": ["D"]}, { "task": "Select the entire file in Vim (visual mode, 1 keystroke)", "answers": ["ggVG"], }, { "task": "Search forward for the exact word UNDER the cursor in Vim", "answers": ["*"], }, { "task": "Replace all occurrences of “foo” with “bar” in the whole file (Vim, one ex command)", "answers": [":%s/foo/bar/g"], }, { "task": "Split the current Vim window horizontally", "answers": [":split", ":sp"], }, { "task": "Delete the word under cursor without yanking in Vim", "answers": ['"_dw'], }, { "task": "Open a file named README.md in a new tab from inside Vim", "answers": [":tabe README.md", ":tabedit README.md"], }, { "task": "Show numbered lines in Vim (toggle on)", "answers": [":set number", ":set nu"], }, {"task": "Move to beginning of file in less(1)", "answers": ["g"]}, {"task": "Jump to the bottom of file in less(1)", "answers": ["G"]}, { "task": "Change directory to the previous directory (bash/zsh built-in)", "answers": ["cd -"], }, { "task": "Recursively remove an empty directory tree named build using one POSIX command", "answers": ["rmdir -p build"], }, { "task": "Copy the directory src recursively to dst preserving attributes (GNU cp)", "answers": ["cp -a src dst", "cp -R --preserve=all src dst"], }, { "task": "Count the number of lines in all *.py files in the current directory (bash, one pipeline)", "answers": ["wc -l *.py", "cat *.py | wc -l"], }, { "task": "Find files larger than 10 MiB anywhere under the current directory (GNU find)", "answers": ["find . -type f -size +10M"], }, { "task": "Archive the directory project into a compressed tarball project.tar.gz", "answers": [ "tar czf project.tar.gz project", "tar -czf project.tar.gz project", ], }, { "task": "Extract project.tar.gz into the current directory (tar autodetect)", "answers": ["tar xf project.tar.gz"], }, { "task": "Show the five most recently modified files in the current dir (GNU ls)", "answers": ["ls -1t | head -n 5", "ls -t | head -5"], }, { "task": "Print the third column of a CSV file data.csv (awk)", "answers": ["awk -F, '{print $3}' data.csv"], }, { "task": "Remove carriage-return characters from file win.txt in-place (sed, GNU)", "answers": ["sed -i 's/\\r$//' win.txt"], }, { "task": "List running processes whose command contains “python” (POSIX)", "answers": ["ps aux | grep python | grep -v grep", "pgrep -af python"], }, { "task": "Show only the IPv4 addresses on all interfaces (iproute2)", "answers": ["ip -4 addr show | grep -oP '(?<=inet\\s)\\d+(\\.\\d+){3}'"], }, { "task": "Create an empty file called temp.txt or update its mtime if it exists (shell)", "answers": ["touch temp.txt"], }, { "task": "Stage **all** changes (new, modified, deleted) in Git", "answers": ["git add -A", "git add ."], }, { "task": "Amend the most recent Git commit message without changing its contents", "answers": ["git commit --amend --no-edit"], }, { "task": "Show a concise, one-line-per-commit log with graph and decorations (Git)", "answers": ["git log --oneline --graph --decorate"], }, { "task": "Create and switch to a new branch named feature/api (Git)", "answers": ["git checkout -b feature/api", "git switch -c feature/api"], }, { "task": "Delete a local branch named hotfix quickly (Git)", "answers": ["git branch -d hotfix", "git branch -D hotfix"], }, { "task": "Temporarily stash uncommitted changes, including untracked files (Git)", "answers": ["git stash -u", "git stash --include-untracked"], }, { "task": "Pull the current branch and rebase instead of merge (Git)", "answers": ["git pull --rebase"], }, { "task": "Show the diff of what is **staged** vs HEAD in Git", "answers": ["git diff --cached", "git diff --staged"], }, { "task": "Reset the current branch to the previous commit, keeping changes unstaged (Git)", "answers": ["git reset --soft HEAD~1"], }, { "task": "List Docker containers that are currently running", "answers": ["docker ps"], }, { "task": "Remove all stopped Docker containers in one command", "answers": ["docker container prune -f", "docker rm $(docker ps -aq)"], }, { "task": "Display real-time memory usage every 2 s in human-readable units (GNU watch / free)", "answers": ["watch -n2 free -h"], }, { "task": "Run a Python one-liner that starts an HTTP server on port 8000", "answers": ["python -m http.server 8000", "python3 -m http.server"], }, { "task": "Show the environment variable PATH in zsh/bash", "answers": ["echo $PATH", "printf '%s\\n' \"$PATH\""], }, { "task": "Replace the first occurrence of word “foo” with “bar” on the **current** line in Vim (normal mode)", "answers": [":s/foo/bar/", "ciwbar"], }, { "task": "Transpose two characters around the cursor in Vim", "answers": ["xp"], }, { "task": "Increment the number under the cursor by 1 in Vim", "answers": [""], }, { "task": "Decrease the number under the cursor by 1 in Vim", "answers": [""], }, { "task": "Open the man page for grep using a single terminal command", "answers": ["man grep"], }, { "task": "Download a file silently via HTTP to out.bin using curl", "answers": [ "curl -s -o out.bin http://example.com/file", "curl -so out.bin http://example.com/file", ], }, { "task": "Generate an SSH key pair using Ed25519 algorithm with default filename", "answers": ["ssh-keygen -t ed25519"], }, { "task": "Show disk usage of each directory in the current path, depth=1 (GNU du)", "answers": ["du -h --max-depth=1", "du -h --summarize */"], }, { "task": "Kill the process listening on TCP port 3000 (Linux, lsof + awk + kill)", "answers": ["kill -9 $(lsof -t -i:3000)", "fuser -k 3000/tcp"], }, { "task": "Print lines 10 through 20 of file notes.txt (sed)", "answers": ["sed -n '10,20p' notes.txt"], }, { "task": "Show only unique lines of file list.txt preserving order (GNU awk)", "answers": ["awk '!seen[$0]++' list.txt"], }, { "task": "Create a symbolic link named latest pointing to build-2025-06-07", "answers": ["ln -s build-2025-06-07 latest"], }, { "task": "Generate a SHA-256 checksum of file iso.img (GNU coreutils)", "answers": ["sha256sum iso.img"], }, { "task": "Display the calendar for June 2025 (util-linux cal)", "answers": ["cal 6 2025"], }, {"task": "Move cursor to line 42 in Vim", "answers": [":42", "42G"]}, {"task": "Repeat last command-line in Vim", "answers": [":@:", ""]}, {"task": "Join next line with current in Vim (normal mode)", "answers": ["J"]}, {"task": "Toggle case of character under cursor in Vim", "answers": ["~"]}, {"task": "Enter insert mode after current character in Vim", "answers": ["a"]}, {"task": "Delete 5 lines starting from current in Vim", "answers": ["5dd"]}, {"task": "Search backward for word in Vim", "answers": ["#"]}, {"task": "Replace current character in Vim (normal mode)", "answers": ["r"]}, {"task": "Go to next tab in Vim", "answers": [":tabn", "gt"]}, {"task": "Split Vim window vertically", "answers": [":vsplit", ":vs"]}, {"task": "List all buffers in Vim", "answers": [":ls", ":buffers"]}, {"task": "Save file without quitting in Vim", "answers": [":w"]}, {"task": "Move to next word in Vim", "answers": ["w"]}, {"task": "Move to previous word in Vim", "answers": ["b"]}, {"task": "Delete until end of word in Vim", "answers": ["dw"]}, {"task": "Yank current line in Vim", "answers": ["yy"]}, {"task": "Paste yanked text above current line in Vim", "answers": ["P"]}, {"task": "Indent current line in Vim (normal mode)", "answers": [">>"]}, {"task": "Unindent current line in Vim (normal mode)", "answers": ["<<"]}, {"task": "Go to first line in Vim", "answers": ["gg", ":1"]}, { "task": "Show file permissions in current directory (ls)", "answers": ["ls -l"], }, {"task": "Create directory named docs (mkdir)", "answers": ["mkdir docs"]}, {"task": "Remove file temp.txt (rm)", "answers": ["rm temp.txt"]}, {"task": "List files sorted by size (GNU ls)", "answers": ["ls -lS"]}, {"task": "Show current working directory (bash)", "answers": ["pwd"]}, {"task": "Copy file a.txt to b.txt (cp)", "answers": ["cp a.txt b.txt"]}, {"task": "Move file a.txt to dir/ (mv)", "answers": ["mv a.txt dir/"]}, { "task": "Show first 10 lines of file.txt (head)", "answers": ["head file.txt"], }, {"task": "Count words in file.txt (wc)", "answers": ["wc -w file.txt"]}, {"task": "Sort lines of file.txt (sort)", "answers": ["sort file.txt"]}, { "task": "Find files modified in last 7 days (GNU find)", "answers": ["find . -type f -mtime -7"], }, {"task": "List all running processes (POSIX ps)", "answers": ["ps aux"]}, {"task": "Kill process by PID 1234 (kill)", "answers": ["kill 1234"]}, {"task": "Show disk usage of current directory (du)", "answers": ["du -sh ."]}, { "task": "Compress file.txt to file.txt.gz (gzip)", "answers": ["gzip file.txt"], }, {"task": "Decompress file.txt.gz (gunzip)", "answers": ["gunzip file.txt.gz"]}, {"task": "Show current user (whoami)", "answers": ["whoami"]}, { "task": "Change file permissions to 644 (chmod)", "answers": ["chmod 644 file.txt"], }, { "task": "Change file owner to user (chown)", "answers": ["chown user file.txt"], }, {"task": "Show system uptime (uptime)", "answers": ["uptime"]}, {"task": "Create empty git repository (git)", "answers": ["git init"]}, {"task": "Show git branch status (git)", "answers": ["git status"]}, { "task": "Commit staged changes with message (git)", "answers": ["git commit -m 'message'"], }, { "task": "Push current branch to origin (git)", "answers": ["git push origin HEAD"], }, {"task": "Pull current branch from origin (git)", "answers": ["git pull"]}, {"task": "Show git branches (git)", "answers": ["git branch"]}, { "task": "Switch to branch main (git)", "answers": ["git checkout main", "git switch main"], }, { "task": "Merge branch feature into current (git)", "answers": ["git merge feature"], }, {"task": "Show git diff of unstaged changes (git)", "answers": ["git diff"]}, {"task": "List all Docker images", "answers": ["docker images"]}, { "task": "Stop Docker container by ID (docker)", "answers": ["docker stop "], }, { "task": "Run container from image nginx (docker)", "answers": ["docker run nginx"], }, { "task": "Show Docker container logs (docker)", "answers": ["docker logs "], }, { "task": "Remove Docker image by ID (docker)", "answers": ["docker rmi "], }, { "task": "Search for pattern in files recursively (grep)", "answers": ["grep -r 'pattern' ."], }, { "task": "Show line numbers in grep output", "answers": ["grep -n 'pattern' file.txt"], }, { "task": "Print first column of file.txt (awk)", "answers": ["awk '{print $1}' file.txt"], }, { "task": "Replace tabs with spaces in file.txt (sed)", "answers": ["sed 's/\t/ /g' file.txt"], }, {"task": "Show current shell (bash/zsh)", "answers": ["echo $SHELL"]}, { "task": "Set environment variable FOO=bar (bash)", "answers": ["export FOO=bar"], }, { "task": "Append text to file.txt (bash)", "answers": ["echo 'text' >> file.txt"], }, {"task": "Show file type of file.txt (file)", "answers": ["file file.txt"]}, {"task": "List network interfaces (ip)", "answers": ["ip link show"]}, {"task": "Ping host 8.8.8.8 4 times (ping)", "answers": ["ping -c 4 8.8.8.8"]}, {"task": "Show open ports (netstat)", "answers": ["netstat -tuln"]}, { "task": "Download file with wget (wget)", "answers": ["wget http://example.com/file"], }, {"task": "Extract tar file.tar (tar)", "answers": ["tar xf file.tar"]}, {"task": "Show calendar for 2025 (cal)", "answers": ["cal 2025"]}, {"task": "Show current date and time (date)", "answers": ["date"]}, { "task": "List cron jobs for current user (crontab)", "answers": ["crontab -l"], }, { "task": "Copy text to clipboard (xclip)", "answers": ["echo 'text' | xclip -sel clip"], }, {"task": "Show CPU info (lscpu)", "answers": ["lscpu"]}, {"task": "Monitor system processes in real-time (top)", "answers": ["top"]}, {"task": "Show memory usage (free)", "answers": ["free -h"]}, { "task": "List all installed packages (apt)", "answers": ["apt list --installed"], }, {"task": "Update package lists (apt)", "answers": ["sudo apt update"]}, {"task": "Install package vim (apt)", "answers": ["sudo apt install vim"]}, {"task": "Search for package vim (apt)", "answers": ["apt search vim"]}, {"task": "Show manual for ls (man)", "answers": ["man ls"]}, {"task": "Check disk space (df)", "answers": ["df -h"]}, { "task": "Create zip archive of dir (zip)", "answers": ["zip -r archive.zip dir"], }, {"task": "Extract zip archive (unzip)", "answers": ["unzip archive.zip"]}, { "task": "Show last 50 lines of file.txt (tail)", "answers": ["tail -n 50 file.txt"], }, { "task": "List files modified today (find)", "answers": ["find . -type f -mtime 0"], }, { "task": "Count files in current directory (find)", "answers": ["find . -type f | wc -l"], }, {"task": "Show git remote repositories (git)", "answers": ["git remote -v"]}, { "task": "Fetch all branches from origin (git)", "answers": ["git fetch origin"], }, {"task": "Show git commit history (git)", "answers": ["git log"]}, { "task": "Revert last commit, keeping changes (git)", "answers": ["git revert HEAD"], }, { "task": "Show staged files in git (git)", "answers": ["git diff --name-only --cached"], }, ] random.shuffle(DEFAULT_QUESTIONS) TIME_LIMIT_SEC = 15 DATA_DIR = Path.home() / ".vim_terminal_trainer" SCORE_FILE = DATA_DIR / "score.json" def load_score() -> Dict[str, int]: if not SCORE_FILE.exists(): return {"high_score": 0, "games_played": 0} try: with SCORE_FILE.open("r", encoding="utf-8") as f: d = json.load(f) return { "high_score": int(d.get("high_score", 0)), "games_played": int(d.get("games_played", 0)), } except Exception: return {"high_score": 0, "games_played": 0} def save_score(high: int, played: int) -> None: DATA_DIR.mkdir(exist_ok=True) with SCORE_FILE.open("w", encoding="utf-8") as f: json.dump({"high_score": high, "games_played": played}, f) def draw_centered( stdscr: "curses._CursesWindow", y: int, text: str, bold: bool = False ) -> None: h, w = stdscr.getmaxyx() x = max(0, (w - len(text)) // 2) if bold: stdscr.addstr(y, x, text, curses.A_BOLD) else: stdscr.addstr(y, x, text) def render_timer(stdscr: "curses._CursesWindow", remaining: float) -> None: bar_len = 20 filled = int(bar_len * remaining / TIME_LIMIT_SEC) bar = "█" * filled + " " * (bar_len - filled) stdscr.addstr(0, 0, f"⏰[{bar}] {remaining:4.1f}s") def sanitize_question(q): """Return a dict with keys 'task' and 'answers' no matter what comes in.""" if isinstance(q, dict): return q if isinstance(q, list): task, *rest = q answers = rest if rest else [] return {"task": str(task), "answers": [str(a) for a in answers]} return {"task": str(q), "answers": []} def ask_question_curses( stdscr: "curses._CursesWindow", q: Dict[str, object], idx: int, total: int, ) -> bool: q = sanitize_question(q) stdscr.clear() h, w = stdscr.getmaxyx() header = f"Question {idx}/{total} – Type the command:" draw_centered(stdscr, 2, header, bold=True) wrapped = textwrap.fill(q["task"], width=min(70, w - 4)) for i, line in enumerate(wrapped.splitlines(), start=4): draw_centered(stdscr, i, line) input_y = 6 + wrapped.count("\n") stdscr.addstr(input_y, 2, "› ") stdscr.refresh() buffer = "" valid = [" ".join(a.split()) for a in q["answers"]] longest = max(len(a) for a in valid) start = time.monotonic() curses.curs_set(1) while True: remaining = TIME_LIMIT_SEC - (time.monotonic() - start) render_timer(stdscr, max(0, remaining)) stdscr.addstr(input_y, 4, buffer + " " * (longest - len(buffer))) stdscr.move(input_y, 4 + len(buffer)) stdscr.refresh() if remaining <= 0: return False stdscr.timeout(100) ch = stdscr.getch() if ch == -1: continue if ch in (curses.KEY_BACKSPACE, 127, 8): buffer = buffer[:-1] continue if ch in (10, 13): break if 0 <= ch <= 255: buffer += chr(ch) norm = " ".join(buffer.split()) if norm in valid: curses.curs_set(0) stdscr.addstr(input_y + 1, 4, "✅ Correct!", curses.A_BOLD) stdscr.refresh() time.sleep(0.4) return True if len(buffer) > longest + 5: buffer = buffer[-(longest + 5) :] norm = " ".join(buffer.split()) return norm in valid def play_game(stdscr: "curses._CursesWindow", pool: List[Dict[str, object]]) -> None: score_data = load_score() curses.curs_set(0) curses.endwin() try: q_default = 10 if len(pool) >= 10 else len(pool) num_q = int(input(f"How many questions? [{q_default}]: ") or q_default) except ValueError: num_q = q_default num_q = max(1, min(num_q, len(pool))) stdscr = curses.initscr() curses.noecho() curses.cbreak() stdscr.keypad(True) questions = random.sample(pool, k=num_q) correct = 0 for idx, q in enumerate(questions, 1): if ask_question_curses(stdscr, q, idx, num_q): correct += 1 stdscr.clear() result_text = f"You scored {correct}/{num_q}!" draw_centered(stdscr, 3, result_text, bold=True) if correct > score_data["high_score"]: draw_centered(stdscr, 5, "🎉 NEW PERSONAL BEST! 🏆", bold=True) score_data["high_score"] = correct score_data["games_played"] += 1 save_score(score_data["high_score"], score_data["games_played"]) draw_centered( stdscr, 7, f"High‑score: {score_data['high_score']} | Games played: {score_data['games_played']}", ) draw_centered(stdscr, 9, "Press any key to return to menu…") stdscr.refresh() stdscr.getch() def main_menu() -> None: pool = DEFAULT_QUESTIONS.copy() while True: os.system("clear") print("=" * 60) print(" VIM / TERMINAL TRAINER ".center(60)) print("=" * 60) sc = load_score() print( f"High‑score: {sc['high_score']} – Games played: {sc['games_played']}\n" ) print("1) Play") print("2) List questions") print("3) Add question") print("4) Reset high‑score") print("5) Quit") choice = input("\nSelect: ").strip() if choice == "1": curses.wrapper(play_game, pool) elif choice == "2": for i, q in enumerate(pool, 1): print(f"{i:>3}. {q['task']}") print(" → " + ", ".join(q["answers"])) input("\nEnter to continue …") elif choice == "3": task = input("Task description (blank to cancel): ").strip() if not task: continue answers = [ a.strip() for a in input("Accepted answers (comma‑sep): ").split(",") if a.strip() ] if answers: pool.append({"task": task, "answers": answers}) elif choice == "4": if input("Really reset scores? [y/N]: ").lower() == "y": save_score(0, 0) elif choice == "5": break if __name__ == "__main__": try: main_menu() except KeyboardInterrupt: print("\nGoodbye!")