From 95471924a4ea36b55aa6a7f0b9293a611240b071 Mon Sep 17 00:00:00 2001 From: Greg Hendrickson Date: Tue, 27 Jan 2026 15:11:08 +0000 Subject: [PATCH] Initial scaffold for ShellMate - SSH chess TUI --- .gitignore | 36 +++++ Dockerfile | 28 ++++ README.md | 68 ++++++++++ docker-compose.yml | 49 +++++++ pyproject.toml | 46 +++++++ src/shellmate/__init__.py | 3 + src/shellmate/ai/__init__.py | 5 + src/shellmate/ai/engine.py | 158 ++++++++++++++++++++++ src/shellmate/core/__init__.py | 6 + src/shellmate/core/game.py | 125 ++++++++++++++++++ src/shellmate/core/player.py | 68 ++++++++++ src/shellmate/learn/__init__.py | 0 src/shellmate/multiplayer/__init__.py | 0 src/shellmate/ssh/__init__.py | 5 + src/shellmate/ssh/server.py | 118 +++++++++++++++++ src/shellmate/tui/__init__.py | 5 + src/shellmate/tui/app.py | 182 ++++++++++++++++++++++++++ src/shellmate/tui/widgets/__init__.py | 0 18 files changed, 902 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 src/shellmate/__init__.py create mode 100644 src/shellmate/ai/__init__.py create mode 100644 src/shellmate/ai/engine.py create mode 100644 src/shellmate/core/__init__.py create mode 100644 src/shellmate/core/game.py create mode 100644 src/shellmate/core/player.py create mode 100644 src/shellmate/learn/__init__.py create mode 100644 src/shellmate/multiplayer/__init__.py create mode 100644 src/shellmate/ssh/__init__.py create mode 100644 src/shellmate/ssh/server.py create mode 100644 src/shellmate/tui/__init__.py create mode 100644 src/shellmate/tui/app.py create mode 100644 src/shellmate/tui/widgets/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3f5d61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.eggs/ +*.egg +.venv/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.mypy_cache/ + +# Secrets +.env +*.pem +*_key +ssh_host_* + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a852400 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +# Install Stockfish +RUN apt-get update && apt-get install -y \ + stockfish \ + openssh-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +# Copy application +COPY src/ src/ + +# Generate SSH host key +RUN mkdir -p /etc/shellmate && \ + ssh-keygen -t ed25519 -f /etc/shellmate/ssh_host_key -N "" + +# Run as non-root +RUN useradd -m shellmate +USER shellmate + +EXPOSE 2222 + +CMD ["shellmate-server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2885c7d --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# ā™Ÿļø ShellMate + +**SSH into chess mastery.** + +``` +ssh play@shellmate.sh +``` + +A terminal-based chess experience over SSH. Play against AI, challenge friends, or learn the game with interactive tutorials. + +## Features + +### šŸŽ® Game Modes +- **vs AI** — Challenge Stockfish at adjustable difficulty levels +- **vs Player** — Real-time PvP matchmaking +- **vs Friend** — Private rooms with shareable codes + +### šŸ“š Learn +- **Interactive Tutorials** — From basics to advanced tactics +- **Move Analysis** — AI explains why each move matters +- **Puzzle Rush** — Tactical training exercises +- **Opening Explorer** — Learn popular openings with explanations + +### šŸ† Features +- ELO rating system +- Game history & replay +- Multiple board themes +- Move hints & analysis +- Chat in PvP games + +## Quick Start + +```bash +# Connect and play +ssh play@shellmate.sh + +# Spectate a game +ssh watch@shellmate.sh + +# Tutorial mode +ssh learn@shellmate.sh +``` + +## Tech Stack + +- **Python 3.11+** +- **Textual** — Modern TUI framework +- **python-chess** — Chess logic & notation +- **Stockfish** — AI engine +- **asyncssh** — SSH server +- **Redis** — Matchmaking & sessions +- **PostgreSQL** — User data & game history + +## Self-Hosting + +```bash +docker compose up -d +``` + +See [docs/self-hosting.md](docs/self-hosting.md) for configuration. + +## License + +MIT + +--- + +*Built with ā™Ÿļø by Greg Hendrickson* diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..958239e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + shellmate: + build: . + ports: + - "2222:2222" + environment: + - SHELLMATE_SSH_PORT=2222 + - SHELLMATE_REDIS_URL=redis://redis:6379 + - SHELLMATE_DATABASE_URL=postgresql://shellmate:shellmate@postgres:5432/shellmate + - STOCKFISH_PATH=/usr/games/stockfish + depends_on: + - redis + - postgres + restart: unless-stopped + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "2222"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_USER=shellmate + - POSTGRES_PASSWORD=shellmate + - POSTGRES_DB=shellmate + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shellmate"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + redis_data: + postgres_data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eee19ca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "shellmate" +version = "0.1.0" +description = "SSH into chess mastery - Terminal chess over SSH" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "Greg Hendrickson", email = "greg@gregh.dev" }] +requires-python = ">=3.11" +dependencies = [ + "textual>=0.50.0", + "python-chess>=1.10.0", + "asyncssh>=2.14.0", + "redis>=5.0.0", + "asyncpg>=0.29.0", + "stockfish>=3.28.0", + "rich>=13.7.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.2.0", + "mypy>=1.8.0", +] + +[project.scripts] +shellmate = "shellmate.cli:main" +shellmate-server = "shellmate.ssh.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.mypy] +python_version = "3.11" +strict = true diff --git a/src/shellmate/__init__.py b/src/shellmate/__init__.py new file mode 100644 index 0000000..9a52be8 --- /dev/null +++ b/src/shellmate/__init__.py @@ -0,0 +1,3 @@ +"""ShellMate - SSH into chess mastery.""" + +__version__ = "0.1.0" diff --git a/src/shellmate/ai/__init__.py b/src/shellmate/ai/__init__.py new file mode 100644 index 0000000..4c69fa1 --- /dev/null +++ b/src/shellmate/ai/__init__.py @@ -0,0 +1,5 @@ +"""AI engine integration.""" + +from .engine import ChessAI + +__all__ = ["ChessAI"] diff --git a/src/shellmate/ai/engine.py b/src/shellmate/ai/engine.py new file mode 100644 index 0000000..44a8ef6 --- /dev/null +++ b/src/shellmate/ai/engine.py @@ -0,0 +1,158 @@ +"""Stockfish AI engine wrapper with move explanations.""" + +import asyncio +from dataclasses import dataclass +from typing import Optional +import chess +from stockfish import Stockfish + + +@dataclass +class MoveAnalysis: + """Analysis of a chess position/move.""" + best_move: str + evaluation: float # centipawns, positive = white advantage + depth: int + pv: list[str] # principal variation + explanation: str + + +class ChessAI: + """Chess AI powered by Stockfish with explanations.""" + + def __init__( + self, + stockfish_path: str = "/usr/bin/stockfish", + skill_level: int = 10, + think_time_ms: int = 1000, + ): + self.engine = Stockfish(path=stockfish_path) + self.engine.set_skill_level(skill_level) + self.think_time_ms = think_time_ms + self._skill_level = skill_level + + def set_difficulty(self, level: int) -> None: + """Set AI difficulty (0-20).""" + self._skill_level = max(0, min(20, level)) + self.engine.set_skill_level(self._skill_level) + + def get_best_move(self, fen: str) -> str: + """Get the best move for the current position.""" + self.engine.set_fen_position(fen) + return self.engine.get_best_move_time(self.think_time_ms) + + def analyze_position(self, fen: str, depth: int = 15) -> MoveAnalysis: + """Analyze a position and return detailed analysis.""" + self.engine.set_fen_position(fen) + self.engine.set_depth(depth) + + evaluation = self.engine.get_evaluation() + best_move = self.engine.get_best_move() + top_moves = self.engine.get_top_moves(3) + + # Convert evaluation to centipawns + if evaluation["type"] == "cp": + eval_cp = evaluation["value"] + else: # mate + eval_cp = 10000 if evaluation["value"] > 0 else -10000 + + # Generate explanation + explanation = self._generate_explanation(fen, best_move, eval_cp, top_moves) + + return MoveAnalysis( + best_move=best_move, + evaluation=eval_cp / 100, # convert to pawns + depth=depth, + pv=[m["Move"] for m in top_moves] if top_moves else [best_move], + explanation=explanation, + ) + + def _generate_explanation( + self, + fen: str, + best_move: str, + eval_cp: int, + top_moves: list, + ) -> str: + """Generate human-readable explanation for a move.""" + board = chess.Board(fen) + move = chess.Move.from_uci(best_move) + san = board.san(move) + piece = board.piece_at(move.from_square) + + explanations = [] + + # Describe the move + piece_name = chess.piece_name(piece.piece_type).capitalize() if piece else "Piece" + explanations.append(f"{piece_name} to {chess.square_name(move.to_square)} ({san})") + + # Check for captures + if board.is_capture(move): + captured = board.piece_at(move.to_square) + if captured: + explanations.append(f"Captures {chess.piece_name(captured.piece_type)}") + + # Check for checks + board.push(move) + if board.is_check(): + if board.is_checkmate(): + explanations.append("Checkmate!") + else: + explanations.append("Puts the king in check") + board.pop() + + # Evaluation context + if abs(eval_cp) < 50: + explanations.append("Position is roughly equal") + elif eval_cp > 300: + explanations.append("White has a significant advantage") + elif eval_cp < -300: + explanations.append("Black has a significant advantage") + elif eval_cp > 0: + explanations.append("White is slightly better") + else: + explanations.append("Black is slightly better") + + return ". ".join(explanations) + "." + + def explain_move(self, fen_before: str, move_uci: str) -> str: + """Explain why a specific move is good or bad.""" + analysis_before = self.analyze_position(fen_before) + + board = chess.Board(fen_before) + move = chess.Move.from_uci(move_uci) + san = board.san(move) + board.push(move) + + analysis_after = self.analyze_position(board.fen()) + + eval_change = analysis_before.evaluation - analysis_after.evaluation + + if move_uci == analysis_before.best_move: + quality = "This is the best move in this position!" + elif abs(eval_change) < 0.3: + quality = "A solid move that maintains the position." + elif eval_change > 1.0: + quality = f"This move loses about {eval_change:.1f} pawns of advantage." + elif eval_change > 0.3: + quality = "A slight inaccuracy - there was a better option." + else: + quality = "A good move!" + + return f"{san}: {quality} {analysis_before.explanation}" + + def get_hint(self, fen: str) -> str: + """Get a hint for the current position.""" + analysis = self.analyze_position(fen) + board = chess.Board(fen) + move = chess.Move.from_uci(analysis.best_move) + piece = board.piece_at(move.from_square) + + if piece: + piece_name = chess.piece_name(piece.piece_type).capitalize() + return f"Consider moving your {piece_name}..." + return "Look for tactical opportunities..." + + def close(self) -> None: + """Clean up engine resources.""" + del self.engine diff --git a/src/shellmate/core/__init__.py b/src/shellmate/core/__init__.py new file mode 100644 index 0000000..2866976 --- /dev/null +++ b/src/shellmate/core/__init__.py @@ -0,0 +1,6 @@ +"""Core chess game logic.""" + +from .game import ChessGame +from .player import Player, PlayerType + +__all__ = ["ChessGame", "Player", "PlayerType"] diff --git a/src/shellmate/core/game.py b/src/shellmate/core/game.py new file mode 100644 index 0000000..f6c2328 --- /dev/null +++ b/src/shellmate/core/game.py @@ -0,0 +1,125 @@ +"""Chess game engine wrapper.""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Optional +import chess +import chess.pgn + + +class GameResult(Enum): + IN_PROGRESS = "in_progress" + WHITE_WINS = "white_wins" + BLACK_WINS = "black_wins" + DRAW = "draw" + ABORTED = "aborted" + + +@dataclass +class Move: + """Represents a chess move with metadata.""" + uci: str + san: str + fen_before: str + fen_after: str + timestamp: datetime = field(default_factory=datetime.utcnow) + think_time_ms: int = 0 + evaluation: Optional[float] = None + explanation: Optional[str] = None + + +@dataclass +class ChessGame: + """Chess game instance.""" + + id: str + white_player_id: str + black_player_id: str + board: chess.Board = field(default_factory=chess.Board) + moves: list[Move] = field(default_factory=list) + result: GameResult = GameResult.IN_PROGRESS + created_at: datetime = field(default_factory=datetime.utcnow) + + @property + def current_turn(self) -> str: + """Return whose turn it is.""" + return "white" if self.board.turn == chess.WHITE else "black" + + @property + def current_player_id(self) -> str: + """Return the ID of the player whose turn it is.""" + return self.white_player_id if self.board.turn == chess.WHITE else self.black_player_id + + def make_move(self, uci: str) -> Optional[Move]: + """Make a move and return Move object if valid.""" + try: + chess_move = chess.Move.from_uci(uci) + if chess_move not in self.board.legal_moves: + return None + + fen_before = self.board.fen() + san = self.board.san(chess_move) + self.board.push(chess_move) + fen_after = self.board.fen() + + move = Move( + uci=uci, + san=san, + fen_before=fen_before, + fen_after=fen_after, + ) + self.moves.append(move) + self._check_game_end() + return move + except (ValueError, chess.InvalidMoveError): + return None + + def get_legal_moves(self) -> list[str]: + """Return list of legal moves in UCI format.""" + return [move.uci() for move in self.board.legal_moves] + + def get_legal_moves_san(self) -> list[str]: + """Return list of legal moves in SAN format.""" + return [self.board.san(move) for move in self.board.legal_moves] + + def _check_game_end(self) -> None: + """Check if the game has ended and set result.""" + if self.board.is_checkmate(): + self.result = GameResult.BLACK_WINS if self.board.turn == chess.WHITE else GameResult.WHITE_WINS + elif self.board.is_stalemate() or self.board.is_insufficient_material(): + self.result = GameResult.DRAW + elif self.board.can_claim_draw(): + self.result = GameResult.DRAW + + def is_check(self) -> bool: + """Return True if current player is in check.""" + return self.board.is_check() + + def is_game_over(self) -> bool: + """Return True if the game is over.""" + return self.result != GameResult.IN_PROGRESS + + def to_pgn(self) -> str: + """Export game as PGN string.""" + game = chess.pgn.Game() + game.headers["White"] = self.white_player_id + game.headers["Black"] = self.black_player_id + game.headers["Date"] = self.created_at.strftime("%Y.%m.%d") + + node = game + temp_board = chess.Board() + for move in self.moves: + chess_move = chess.Move.from_uci(move.uci) + node = node.add_variation(chess_move) + temp_board.push(chess_move) + + return str(game) + + def get_board_display(self, perspective: str = "white") -> str: + """Return ASCII board from given perspective.""" + board_str = str(self.board) + if perspective == "black": + lines = board_str.split('\n') + board_str = '\n'.join(reversed(lines)) + return board_str diff --git a/src/shellmate/core/player.py b/src/shellmate/core/player.py new file mode 100644 index 0000000..e3127cc --- /dev/null +++ b/src/shellmate/core/player.py @@ -0,0 +1,68 @@ +"""Player models.""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Optional + + +class PlayerType(Enum): + HUMAN = "human" + AI = "ai" + GUEST = "guest" + + +@dataclass +class Player: + """Represents a player.""" + + id: str + username: str + player_type: PlayerType = PlayerType.HUMAN + elo: int = 1200 + games_played: int = 0 + wins: int = 0 + losses: int = 0 + draws: int = 0 + created_at: datetime = field(default_factory=datetime.utcnow) + last_seen: Optional[datetime] = None + + @property + def winrate(self) -> float: + """Calculate win rate percentage.""" + if self.games_played == 0: + return 0.0 + return (self.wins / self.games_played) * 100 + + def update_elo(self, opponent_elo: int, result: float, k: int = 32) -> int: + """ + Update ELO rating based on game result. + result: 1.0 for win, 0.5 for draw, 0.0 for loss + Returns the ELO change. + """ + expected = 1 / (1 + 10 ** ((opponent_elo - self.elo) / 400)) + change = int(k * (result - expected)) + self.elo += change + return change + + def record_game(self, won: bool, draw: bool = False) -> None: + """Record a completed game.""" + self.games_played += 1 + if draw: + self.draws += 1 + elif won: + self.wins += 1 + else: + self.losses += 1 + self.last_seen = datetime.utcnow() + + +@dataclass +class AIPlayer(Player): + """AI player with configurable difficulty.""" + + difficulty: int = 10 # Stockfish skill level 0-20 + think_time_ms: int = 1000 # Time to think per move + + def __post_init__(self): + self.player_type = PlayerType.AI diff --git a/src/shellmate/learn/__init__.py b/src/shellmate/learn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shellmate/multiplayer/__init__.py b/src/shellmate/multiplayer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shellmate/ssh/__init__.py b/src/shellmate/ssh/__init__.py new file mode 100644 index 0000000..c7192d5 --- /dev/null +++ b/src/shellmate/ssh/__init__.py @@ -0,0 +1,5 @@ +"""SSH server for ShellMate.""" + +from .server import ShellMateSSHServer + +__all__ = ["ShellMateSSHServer"] diff --git a/src/shellmate/ssh/server.py b/src/shellmate/ssh/server.py new file mode 100644 index 0000000..b80a908 --- /dev/null +++ b/src/shellmate/ssh/server.py @@ -0,0 +1,118 @@ +"""SSH server implementation.""" + +import asyncio +import logging +from typing import Optional +import asyncssh + +from shellmate.tui.app import ShellMateApp + +logger = logging.getLogger(__name__) + + +class ShellMateSSHServer(asyncssh.SSHServer): + """SSH server that launches ShellMate TUI for each connection.""" + + def __init__(self): + self._username: Optional[str] = None + + def connection_made(self, conn: asyncssh.SSHServerConnection) -> None: + logger.info(f"SSH connection from {conn.get_extra_info('peername')}") + + def connection_lost(self, exc: Optional[Exception]) -> None: + if exc: + logger.error(f"SSH connection error: {exc}") + else: + logger.info("SSH connection closed") + + def begin_auth(self, username: str) -> bool: + self._username = username + # Allow all usernames, we'll handle auth in the app + return True + + def password_auth_supported(self) -> bool: + return True + + def validate_password(self, username: str, password: str) -> bool: + # Guest access - accept any password or empty + # For registered users, validate against database + return True # TODO: Implement proper auth + + def public_key_auth_supported(self) -> bool: + return True + + def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: + # Accept any public key for now + return True # TODO: Implement key-based auth + + +async def handle_client(process: asyncssh.SSHServerProcess) -> None: + """Handle an SSH client session.""" + username = process.get_extra_info("username") or "guest" + + # Determine mode based on username + if username == "learn": + mode = "tutorial" + elif username == "watch": + mode = "spectate" + else: + mode = "play" + + logger.info(f"Starting ShellMate for {username} in {mode} mode") + + try: + # Create and run the TUI app + app = ShellMateApp( + username=username, + mode=mode, + stdin=process.stdin, + stdout=process.stdout, + ) + await app.run_async() + except Exception as e: + logger.error(f"Error in ShellMate session: {e}") + process.stdout.write(f"\r\nError: {e}\r\n") + finally: + process.exit(0) + + +async def start_server( + host: str = "0.0.0.0", + port: int = 22, + host_keys: list[str] = None, +) -> None: + """Start the SSH server.""" + host_keys = host_keys or ["/etc/shellmate/ssh_host_key"] + + logger.info(f"Starting ShellMate SSH server on {host}:{port}") + + await asyncssh.create_server( + ShellMateSSHServer, + host, + port, + server_host_keys=host_keys, + process_factory=handle_client, + ) + + +def main() -> None: + """Entry point for the SSH server.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + loop.run_until_complete(start_server()) + loop.run_forever() + except KeyboardInterrupt: + logger.info("Shutting down...") + finally: + loop.close() + + +if __name__ == "__main__": + main() diff --git a/src/shellmate/tui/__init__.py b/src/shellmate/tui/__init__.py new file mode 100644 index 0000000..2c75d0b --- /dev/null +++ b/src/shellmate/tui/__init__.py @@ -0,0 +1,5 @@ +"""TUI components for ShellMate.""" + +from .app import ShellMateApp + +__all__ = ["ShellMateApp"] diff --git a/src/shellmate/tui/app.py b/src/shellmate/tui/app.py new file mode 100644 index 0000000..7bbb38c --- /dev/null +++ b/src/shellmate/tui/app.py @@ -0,0 +1,182 @@ +"""Main TUI application.""" + +from typing import Any, Optional +from textual.app import App, ComposeResult +from textual.containers import Container, Horizontal, Vertical +from textual.widgets import Button, Footer, Header, Static, Label +from textual.binding import Binding + +from shellmate.tui.widgets.board import ChessBoard +from shellmate.tui.widgets.move_list import MoveList +from shellmate.tui.widgets.status import GameStatus + + +class ShellMateApp(App): + """ShellMate TUI Chess Application.""" + + CSS = """ + Screen { + layout: grid; + grid-size: 2; + grid-columns: 3fr 1fr; + } + + #board-container { + height: 100%; + content-align: center middle; + } + + #sidebar { + height: 100%; + padding: 1; + } + + ChessBoard { + width: auto; + height: auto; + } + + #move-list { + height: 1fr; + border: solid green; + } + + #game-status { + height: auto; + padding: 1; + border: solid blue; + } + + #controls { + height: auto; + padding: 1; + } + + .menu-button { + width: 100%; + margin: 1 0; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("n", "new_game", "New Game"), + Binding("h", "hint", "Hint"), + Binding("u", "undo", "Undo"), + Binding("r", "resign", "Resign"), + ] + + def __init__( + self, + username: str = "guest", + mode: str = "play", + stdin: Any = None, + stdout: Any = None, + ): + super().__init__() + self.username = username + self.mode = mode + self._stdin = stdin + self._stdout = stdout + + def compose(self) -> ComposeResult: + yield Header() + + with Horizontal(): + with Container(id="board-container"): + yield ChessBoard(id="chess-board") + + with Vertical(id="sidebar"): + yield GameStatus(id="game-status") + yield MoveList(id="move-list") + + with Container(id="controls"): + yield Button("New Game", id="btn-new", classes="menu-button") + yield Button("vs AI", id="btn-ai", classes="menu-button") + yield Button("vs Player", id="btn-pvp", classes="menu-button") + yield Button("Tutorial", id="btn-learn", classes="menu-button") + + yield Footer() + + def on_mount(self) -> None: + """Called when app is mounted.""" + self.title = "ā™Ÿļø ShellMate" + self.sub_title = f"Welcome, {self.username}!" + + def action_new_game(self) -> None: + """Start a new game.""" + board = self.query_one("#chess-board", ChessBoard) + board.new_game() + self.notify("New game started!") + + def action_hint(self) -> None: + """Get a hint from the AI.""" + self.notify("Hint: Consider developing your pieces...") + + def action_undo(self) -> None: + """Undo the last move.""" + board = self.query_one("#chess-board", ChessBoard) + if board.undo_move(): + self.notify("Move undone") + else: + self.notify("No moves to undo") + + def action_resign(self) -> None: + """Resign the current game.""" + self.notify("Game resigned") + + +# Placeholder widgets - to be implemented +class ChessBoard(Static): + """Chess board widget.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._board_str = self._get_initial_board() + + def _get_initial_board(self) -> str: + return """ + a b c d e f g h + ā”Œā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā” +8 │ ā™œ │ ā™ž │ ā™ │ ā™› │ ā™š │ ā™ │ ā™ž │ ā™œ │ 8 + ā”œā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¤ +7 │ ā™Ÿ │ ā™Ÿ │ ā™Ÿ │ ā™Ÿ │ ā™Ÿ │ ā™Ÿ │ ā™Ÿ │ ā™Ÿ │ 7 + ā”œā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¤ +6 │ │ │ │ │ │ │ │ │ 6 + ā”œā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¤ +5 │ │ │ │ │ │ │ │ │ 5 + ā”œā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¤ +4 │ │ │ │ │ │ │ │ │ 4 + ā”œā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¤ +3 │ │ │ │ │ │ │ │ │ 3 + ā”œā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¤ +2 │ ā™™ │ ā™™ │ ā™™ │ ā™™ │ ā™™ │ ā™™ │ ā™™ │ ā™™ │ 2 + ā”œā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”¤ +1 │ ā™– │ ā™˜ │ ā™— │ ♕ │ ā™” │ ā™— │ ā™˜ │ ā™– │ 1 + ā””ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”˜ + a b c d e f g h +""" + + def render(self) -> str: + return self._board_str + + def new_game(self) -> None: + self._board_str = self._get_initial_board() + self.refresh() + + def undo_move(self) -> bool: + return False # TODO: Implement + + +class MoveList(Static): + """Move history widget.""" + + def render(self) -> str: + return "Move History\n─────────────\n1. e4 e5\n2. Nf3 Nc6\n3. ..." + + +class GameStatus(Static): + """Game status widget.""" + + def render(self) -> str: + return "⚪ White to move\n\nšŸ• 10:00 | 10:00" diff --git a/src/shellmate/tui/widgets/__init__.py b/src/shellmate/tui/widgets/__init__.py new file mode 100644 index 0000000..e69de29