# Chess CLI — Agent Guide

## Project Overview

Terminal chess game in Python. Play against Stockfish with real-time eval and post-game analysis. Includes an opening learning system with walkthrough, drill, and practice modes.

## Stack

- **Python 3.10+** with **python-chess** and **textual** (TUI framework)
- **uv** for package management, **hatchling** build backend
- **Stockfish** must be on PATH (`brew install stockfish`)
- Entry point: `chess-cli = "chess_cli.main:main"`

## Running

```bash
uv sync                                           # install deps
uv run chess-cli                                  # interactive menu
uv run chess-cli play --color white --difficulty beginner
uv run chess-cli analyze game.pgn --depth 18
uv run chess-cli --version
```

## Project Structure

```
chess_cli/
├── main.py              # CLI entry, argparse, launches Textual app
├── board_render.py      # Legacy ANSI rendering (kept for analysis.py color constants)
├── engine.py            # Stockfish wrapper: difficulty presets, eval, move generation
├── game.py              # GameState: board, move history, PGN, undo (model)
├── analysis.py          # Post-game analysis: CP loss, classification, report
├── settings.py          # Persistent user settings (JSON at ~/.config/chess-cli/)
├── openings/
│   ├── __init__.py      # Opening registry — lazy-loads and validates all modules
│   ├── models.py        # Opening/OpeningNode dataclasses, flatten_main_line, validate
│   └── london_system.py # London System opening data
└── tui/
    ├── __init__.py      # Exports ChessApp
    ├── app.py           # ChessApp(App) — screen routing, CLI arg dispatch
    ├── screens/
    │   ├── menu.py          # MenuScreen — color/difficulty/engine selection + openings entry
    │   ├── game.py          # GameScreen — board, eval, moves, engine workers
    │   ├── analysis.py      # AnalysisScreen — progress bar, results display
    │   ├── opening_select.py # OpeningSelectScreen — browse available openings
    │   ├── opening_mode.py  # OpeningModeScreen — choose walkthrough/drill/practice + settings
    │   ├── walkthrough.py   # WalkthroughScreen — step through opening with explanations
    │   ├── drill.py         # DrillScreen — interactive quiz on opening moves
    │   └── practice.py      # PracticeScreen — play vs Stockfish with book feedback
    ├── widgets/
    │   ├── board.py         # BoardWidget — Rich-rendered chess board
    │   ├── eval_bar.py      # EvalBarWidget — vertical eval bar
    │   ├── move_list.py     # MoveListWidget — scrollable two-column notation
    │   ├── player_info.py   # PlayerInfoWidget — name, captured pieces
    │   ├── promotion.py     # PromotionModal — piece selection dialog
    │   └── explanation.py   # ExplanationWidget — opening move SAN + explanation text
    └── styles/
        └── app.tcss     # Global Textual CSS
```

## Architecture Rules

### Module Boundaries
- **game.py**, **engine.py**, and **settings.py** have no cross-dependencies — they are independent
- **openings/** depends only on `python-chess` — no UI imports
- **analysis.py** depends only on board_render (for ANSI color constants)
- **main.py** is minimal — argparse + launches `ChessApp`
- **tui/** contains all UI code: screens orchestrate widgets, widgets are independent
- **tui/screens/game.py** imports from game.py, engine.py, and all widgets — it is the primary orchestrator
- **tui/screens/practice.py** imports from game.py, engine.py, openings/models.py, and widgets
- **tui/screens/opening_mode.py** imports from settings.py and passes config to PracticeScreen via constructor
- **engine.py** is lazily imported inside functions/workers (not at module top level) to avoid Stockfish initialization when not needed

### Threading (Textual Workers)
- All Stockfish calls (get_move, get_evaluation, analyze_game) run in `@work(thread=True)` workers
- Workers use `board.copy()` to avoid racing with the main thread
- Workers use `self.app.call_from_thread()` to update UI state on the main thread
- Engine workers use `exclusive=True, group="engine"` to prevent concurrent engine access
- `on_unmount` cancels the engine worker group before calling `engine.quit()`

### Rendering
- All render functions return strings, never print directly. Caller prints.
- All ANSI escape codes are defined in `board_render.py` as module-level constants. No other module defines ANSI codes — they import from board_render.
- Board cells are 3 characters wide (space-piece-space) for square proportions.

### Evaluation Perspective
- Eval is **always stored and passed as White's perspective** internally.
- Display layer negates for Black when rendering: `display_eval = eval_score if perspective == chess.WHITE else -eval_score`
- CP loss in analysis flips based on which side moved.
- Never store or pass eval from a relative/side-to-move perspective.

### State Management
- `GameState` is the single source of truth for game state.
- Parallel tracking: `move_history` (chess.Move objects) and `san_history` (SAN strings). Keep both in sync.
- SAN must be generated **before** pushing the move (`board.san(move)` then `board.push(move)`).
- Undo removes 2 half-moves (player + engine) when it's the player's turn, 1 when it's the engine's turn.

### Engine Integration
- Stockfish difficulty uses `UCI_LimitStrength` + `UCI_Elo`. Minimum ELO for Stockfish 18 is 1320.
- Difficulty presets: 20 levels from Newcomer(1320) to Supreme(3100) in ~100 ELO steps, plus Maximum(no limit).
- Engine calls wrapped in try/except for `EngineTerminatedError` — game continues without eval if engine crashes.
- Always call `engine.quit()` in a `finally` block to avoid hanging processes.

### Move Input
- Castling normalized: `0-0` -> `O-O`, `0-0-0` -> `O-O-O` before parsing.
- Promotion: if pawn-to-8th-rank fails parse, detect and prompt for piece (default Queen).
- Validation delegated to python-chess (`parse_san`), catching `InvalidMoveError`, `IllegalMoveError`, `AmbiguousMoveError`.

### Screen Flow

```
MenuScreen
├── GameScreen → AnalysisScreen
└── OpeningSelectScreen
    └── OpeningModeScreen
        ├── WalkthroughScreen
        ├── DrillScreen
        └── PracticeScreen
```

Screens are pushed via `push_screen()` and popped via `pop_screen()`. State is passed through constructor parameters — there is no global state store.

### Opening System

Opening data lives in `chess_cli/openings/`. Each opening is an `OpeningNode` tree:
- `move` (SAN string), `explanation` (why the move is played), `children` (next moves)
- Wrapped in an `Opening` dataclass with `name`, `slug`, `color`, `description`, `root`
- `flatten_main_line()` walks `children[0]` down the tree — this is what walkthrough and drill use
- `validate_opening()` replays the main line on a board to catch SAN errors

**Registry** (`openings/__init__.py`): Add new openings by creating a module with an `OPENING` constant and adding its path to `_OPENING_MODULES`. Openings are lazy-loaded and validated on first access.

**Three learning modes:**
- **WalkthroughScreen** — read-only step through, arrow keys to navigate, shows explanations
- **DrillScreen** — quiz mode, player enters moves, tracks correct/total, supports `hint` and `restart` commands
- **PracticeScreen** — full game vs Stockfish with book tracking overlay. Compares player/engine moves against the opening tree node by node. On deviation or undo, book tracking resets (`_in_book = False`, `_current_node = None`)

**Book tracking in PracticeScreen:**
- `_current_node` walks the opening tree as moves are played
- `_check_player_book_move(san)` compares player's SAN against `_current_node.move`, advances on match
- `_check_engine_book_move(san)` does the same for the engine's response
- On any deviation (player or engine), tracking stops permanently for the session
- Undo resets book tracking entirely — it does NOT rebuild the tree position

### User Settings

Persistent settings in `chess_cli/settings.py`:
- Stored at `~/.config/chess-cli/settings.json` (respects `XDG_CONFIG_HOME`)
- `Settings` dataclass — add new fields with defaults for backward compatibility
- `load_settings()` returns defaults on missing/corrupt file; `save_settings()` silently ignores write errors
- Settings are read on screen mount, written immediately on change
- Unknown JSON keys are ignored (forward-compatible)

Current settings:

| Setting | Default | Where toggled |
|---------|---------|---------------|
| `show_undo_button` | `false` | OpeningModeScreen → Practice Settings |

**When adding a new setting:** Add a field to the `Settings` dataclass with a default value. Add a UI toggle in the appropriate screen. The JSON file handles missing/extra keys automatically.

## Common Gotchas

- **SAN before push**: Always call `board.san(move)` before `board.push(move)` — SAN generation needs the pre-move board state.
- **Perspective inversion**: Eval is always White's perspective. Negate for Black display.
- **Parallel histories**: `move_history` and `san_history` must stay in sync through all operations (push, pop, undo).
- **Late engine import**: `EnginePlayer` is imported inside functions, not at module level. Don't assume it's available at parse time. Use `TYPE_CHECKING` guard for type hints.
- **PGN objects**: `to_pgn()` returns `chess.pgn.Game`, not a string. Use `str()` to convert.
- **Engine lifecycle**: Stockfish process must be explicitly quit. Leaked processes hang.
- **Opening validation**: `validate_opening()` only checks the main line (`children[0]` path). Sideline branches are not validated automatically.
- **Book tracking is one-way**: Once `_in_book` is set to `False` in PracticeScreen, it stays false for the session. There is no mechanism to re-enter book tracking after deviation or undo.
- **Settings defaults**: New `Settings` fields must have defaults so `load_settings()` works with older JSON files that lack the key.

## No Tests

No test suite exists yet. Manual testing via `uv run chess-cli`.
