This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 autoadvances to the next question. This is now implemented with the
standardlibrary **curses** module, so it should run on any Unixlike terminal
(macOS, Linux, WSL, etc.).
Key changes
-----------
* Realtime character capture with `curses` no blocking `input()` inside the
quiz loop.
* Timer bar counts down in the topright corner.
* Still stores a persistent highscore 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 (normalmode, 1 cmd)",
"answers": ["gUiw"],
},
{"task": "Switch to the previous buffer in Vim", "answers": [":b#", "<C-^>", "^"]},
{
"task": "Undo the last change in Vim (normal-mode, one command)",
"answers": ["u"],
},
{"task": "Redo the last undone change in Vim", "answers": ["<C-r>"]},
{"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<Esc>"],
},
{
"task": "Transpose two characters around the cursor in Vim",
"answers": ["xp"],
},
{
"task": "Increment the number under the cursor by 1 in Vim",
"answers": ["<C-a>"],
},
{
"task": "Decrease the number under the cursor by 1 in Vim",
"answers": ["<C-x>"],
},
{
"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": [":@:", "<C-p>"]},
{"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 <container_id>"],
},
{
"task": "Run container from image nginx (docker)",
"answers": ["docker run nginx"],
},
{
"task": "Show Docker container logs (docker)",
"answers": ["docker logs <container_id>"],
},
{
"task": "Remove Docker image by ID (docker)",
"answers": ["docker rmi <image_id>"],
},
{
"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"Highscore: {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"Highscore: {sc['high_score']} Games played: {sc['games_played']}\n"
)
print("1) Play")
print("2) List questions")
print("3) Add question")
print("4) Reset highscore")
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 (commasep): ").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!")