Vibe coded short cuts trainer.

This commit is contained in:
retoor 2025-06-07 11:35:05 +02:00
parent cd9422355a
commit daf77f0ba2

693
shortcuts.py Normal file
View File

@ -0,0 +1,693 @@
#!/usr/bin/env python3
"""
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
# ------------------- DATA & CONFIG -----------------------------------------
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"
# ------------------- PERSISTENCE -------------------------------------------
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)
# ------------------- CURSES UI HELPERS -------------------------------------
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")
# ------------------- GAMEPLAY ----------------------------------------------
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) # ← new normalise input
stdscr.clear()
h, w = stdscr.getmaxyx()
# Header
header = f"Question {idx}/{total} Type the command:"
draw_centered(stdscr, 2, header, bold=True)
# Task text (wrapped)
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 prompt area
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
# Nonblocking read (100ms chunks)
stdscr.timeout(100)
ch = stdscr.getch()
if ch == -1:
continue # no keypress yet
if ch in (curses.KEY_BACKSPACE, 127, 8):
buffer = buffer[:-1]
continue
if ch in (10, 13):
# User pressed Return anyway treat as submission
break
if 0 <= ch <= 255:
buffer += chr(ch)
# Autocheck
norm = " ".join(buffer.split())
if norm in valid:
# Flash feedback
curses.curs_set(0)
stdscr.addstr(input_y + 1, 4, "✅ Correct!", curses.A_BOLD)
stdscr.refresh()
time.sleep(0.4)
return True
# Trim oversized buffer to keep UI tidy
if len(buffer) > longest + 5:
buffer = buffer[-(longest + 5) :]
# Manual submission (Return) or timeout
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)
# Ask number of questions (simple prompt outside curses because of text input)
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)))
# Back into curses
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
# Final screen
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()
# ------------------- MAIN ---------------------------------------------------
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!")