Fix linting: remove trailing whitespace

This commit is contained in:
Greg Hendrickson
2026-01-27 20:49:46 +00:00
parent d698caf846
commit ff643e1265
14 changed files with 395 additions and 395 deletions

View File

@@ -19,7 +19,7 @@ class MoveAnalysis:
class ChessAI: class ChessAI:
"""Chess AI powered by Stockfish with explanations.""" """Chess AI powered by Stockfish with explanations."""
def __init__( def __init__(
self, self,
stockfish_path: str = "/usr/bin/stockfish", stockfish_path: str = "/usr/bin/stockfish",
@@ -30,35 +30,35 @@ class ChessAI:
self.engine.set_skill_level(skill_level) self.engine.set_skill_level(skill_level)
self.think_time_ms = think_time_ms self.think_time_ms = think_time_ms
self._skill_level = skill_level self._skill_level = skill_level
def set_difficulty(self, level: int) -> None: def set_difficulty(self, level: int) -> None:
"""Set AI difficulty (0-20).""" """Set AI difficulty (0-20)."""
self._skill_level = max(0, min(20, level)) self._skill_level = max(0, min(20, level))
self.engine.set_skill_level(self._skill_level) self.engine.set_skill_level(self._skill_level)
def get_best_move(self, fen: str) -> str: def get_best_move(self, fen: str) -> str:
"""Get the best move for the current position.""" """Get the best move for the current position."""
self.engine.set_fen_position(fen) self.engine.set_fen_position(fen)
return self.engine.get_best_move_time(self.think_time_ms) return self.engine.get_best_move_time(self.think_time_ms)
def analyze_position(self, fen: str, depth: int = 15) -> MoveAnalysis: def analyze_position(self, fen: str, depth: int = 15) -> MoveAnalysis:
"""Analyze a position and return detailed analysis.""" """Analyze a position and return detailed analysis."""
self.engine.set_fen_position(fen) self.engine.set_fen_position(fen)
self.engine.set_depth(depth) self.engine.set_depth(depth)
evaluation = self.engine.get_evaluation() evaluation = self.engine.get_evaluation()
best_move = self.engine.get_best_move() best_move = self.engine.get_best_move()
top_moves = self.engine.get_top_moves(3) top_moves = self.engine.get_top_moves(3)
# Convert evaluation to centipawns # Convert evaluation to centipawns
if evaluation["type"] == "cp": if evaluation["type"] == "cp":
eval_cp = evaluation["value"] eval_cp = evaluation["value"]
else: # mate else: # mate
eval_cp = 10000 if evaluation["value"] > 0 else -10000 eval_cp = 10000 if evaluation["value"] > 0 else -10000
# Generate explanation # Generate explanation
explanation = self._generate_explanation(fen, best_move, eval_cp, top_moves) explanation = self._generate_explanation(fen, best_move, eval_cp, top_moves)
return MoveAnalysis( return MoveAnalysis(
best_move=best_move, best_move=best_move,
evaluation=eval_cp / 100, # convert to pawns evaluation=eval_cp / 100, # convert to pawns
@@ -66,7 +66,7 @@ class ChessAI:
pv=[m["Move"] for m in top_moves] if top_moves else [best_move], pv=[m["Move"] for m in top_moves] if top_moves else [best_move],
explanation=explanation, explanation=explanation,
) )
def _generate_explanation( def _generate_explanation(
self, self,
fen: str, fen: str,
@@ -79,19 +79,19 @@ class ChessAI:
move = chess.Move.from_uci(best_move) move = chess.Move.from_uci(best_move)
san = board.san(move) san = board.san(move)
piece = board.piece_at(move.from_square) piece = board.piece_at(move.from_square)
explanations = [] explanations = []
# Describe the move # Describe the move
piece_name = chess.piece_name(piece.piece_type).capitalize() if piece else "Piece" 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})") explanations.append(f"{piece_name} to {chess.square_name(move.to_square)} ({san})")
# Check for captures # Check for captures
if board.is_capture(move): if board.is_capture(move):
captured = board.piece_at(move.to_square) captured = board.piece_at(move.to_square)
if captured: if captured:
explanations.append(f"Captures {chess.piece_name(captured.piece_type)}") explanations.append(f"Captures {chess.piece_name(captured.piece_type)}")
# Check for checks # Check for checks
board.push(move) board.push(move)
if board.is_check(): if board.is_check():
@@ -100,7 +100,7 @@ class ChessAI:
else: else:
explanations.append("Puts the king in check") explanations.append("Puts the king in check")
board.pop() board.pop()
# Evaluation context # Evaluation context
if abs(eval_cp) < 50: if abs(eval_cp) < 50:
explanations.append("Position is roughly equal") explanations.append("Position is roughly equal")
@@ -112,22 +112,22 @@ class ChessAI:
explanations.append("White is slightly better") explanations.append("White is slightly better")
else: else:
explanations.append("Black is slightly better") explanations.append("Black is slightly better")
return ". ".join(explanations) + "." return ". ".join(explanations) + "."
def explain_move(self, fen_before: str, move_uci: str) -> str: def explain_move(self, fen_before: str, move_uci: str) -> str:
"""Explain why a specific move is good or bad.""" """Explain why a specific move is good or bad."""
analysis_before = self.analyze_position(fen_before) analysis_before = self.analyze_position(fen_before)
board = chess.Board(fen_before) board = chess.Board(fen_before)
move = chess.Move.from_uci(move_uci) move = chess.Move.from_uci(move_uci)
san = board.san(move) san = board.san(move)
board.push(move) board.push(move)
analysis_after = self.analyze_position(board.fen()) analysis_after = self.analyze_position(board.fen())
eval_change = analysis_before.evaluation - analysis_after.evaluation eval_change = analysis_before.evaluation - analysis_after.evaluation
if move_uci == analysis_before.best_move: if move_uci == analysis_before.best_move:
quality = "This is the best move in this position!" quality = "This is the best move in this position!"
elif abs(eval_change) < 0.3: elif abs(eval_change) < 0.3:
@@ -138,21 +138,21 @@ class ChessAI:
quality = "A slight inaccuracy - there was a better option." quality = "A slight inaccuracy - there was a better option."
else: else:
quality = "A good move!" quality = "A good move!"
return f"{san}: {quality} {analysis_before.explanation}" return f"{san}: {quality} {analysis_before.explanation}"
def get_hint(self, fen: str) -> str: def get_hint(self, fen: str) -> str:
"""Get a hint for the current position.""" """Get a hint for the current position."""
analysis = self.analyze_position(fen) analysis = self.analyze_position(fen)
board = chess.Board(fen) board = chess.Board(fen)
move = chess.Move.from_uci(analysis.best_move) move = chess.Move.from_uci(analysis.best_move)
piece = board.piece_at(move.from_square) piece = board.piece_at(move.from_square)
if piece: if piece:
piece_name = chess.piece_name(piece.piece_type).capitalize() piece_name = chess.piece_name(piece.piece_type).capitalize()
return f"Consider moving your {piece_name}..." return f"Consider moving your {piece_name}..."
return "Look for tactical opportunities..." return "Look for tactical opportunities..."
def close(self) -> None: def close(self) -> None:
"""Clean up engine resources.""" """Clean up engine resources."""
del self.engine del self.engine

View File

@@ -16,20 +16,20 @@ Examples:
shellmate --mode learn Start tutorial mode shellmate --mode learn Start tutorial mode
""" """
) )
parser.add_argument( parser.add_argument(
"--mode", "-m", "--mode", "-m",
choices=["play", "ai", "learn", "watch"], choices=["play", "ai", "learn", "watch"],
default="play", default="play",
help="Game mode (default: play)" help="Game mode (default: play)"
) )
parser.add_argument( parser.add_argument(
"--username", "-u", "--username", "-u",
default="guest", default="guest",
help="Username (default: guest)" help="Username (default: guest)"
) )
parser.add_argument( parser.add_argument(
"--difficulty", "-d", "--difficulty", "-d",
type=int, type=int,
@@ -38,20 +38,20 @@ Examples:
metavar="0-20", metavar="0-20",
help="AI difficulty level (default: 10)" help="AI difficulty level (default: 10)"
) )
parser.add_argument( parser.add_argument(
"--version", "-v", "--version", "-v",
action="store_true", action="store_true",
help="Show version" help="Show version"
) )
args = parser.parse_args() args = parser.parse_args()
if args.version: if args.version:
from shellmate import __version__ from shellmate import __version__
print(f"ShellMate v{__version__}") print(f"ShellMate v{__version__}")
sys.exit(0) sys.exit(0)
# Launch TUI # Launch TUI
from shellmate.tui.app import ShellMateApp from shellmate.tui.app import ShellMateApp
app = ShellMateApp(username=args.username, mode=args.mode) app = ShellMateApp(username=args.username, mode=args.mode)

View File

@@ -32,7 +32,7 @@ class Move:
@dataclass @dataclass
class ChessGame: class ChessGame:
"""Chess game instance.""" """Chess game instance."""
id: str id: str
white_player_id: str white_player_id: str
black_player_id: str black_player_id: str
@@ -40,29 +40,29 @@ class ChessGame:
moves: list[Move] = field(default_factory=list) moves: list[Move] = field(default_factory=list)
result: GameResult = GameResult.IN_PROGRESS result: GameResult = GameResult.IN_PROGRESS
created_at: datetime = field(default_factory=datetime.utcnow) created_at: datetime = field(default_factory=datetime.utcnow)
@property @property
def current_turn(self) -> str: def current_turn(self) -> str:
"""Return whose turn it is.""" """Return whose turn it is."""
return "white" if self.board.turn == chess.WHITE else "black" return "white" if self.board.turn == chess.WHITE else "black"
@property @property
def current_player_id(self) -> str: def current_player_id(self) -> str:
"""Return the ID of the player whose turn it is.""" """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 return self.white_player_id if self.board.turn == chess.WHITE else self.black_player_id
def make_move(self, uci: str) -> Optional[Move]: def make_move(self, uci: str) -> Optional[Move]:
"""Make a move and return Move object if valid.""" """Make a move and return Move object if valid."""
try: try:
chess_move = chess.Move.from_uci(uci) chess_move = chess.Move.from_uci(uci)
if chess_move not in self.board.legal_moves: if chess_move not in self.board.legal_moves:
return None return None
fen_before = self.board.fen() fen_before = self.board.fen()
san = self.board.san(chess_move) san = self.board.san(chess_move)
self.board.push(chess_move) self.board.push(chess_move)
fen_after = self.board.fen() fen_after = self.board.fen()
move = Move( move = Move(
uci=uci, uci=uci,
san=san, san=san,
@@ -74,15 +74,15 @@ class ChessGame:
return move return move
except (ValueError, chess.InvalidMoveError): except (ValueError, chess.InvalidMoveError):
return None return None
def get_legal_moves(self) -> list[str]: def get_legal_moves(self) -> list[str]:
"""Return list of legal moves in UCI format.""" """Return list of legal moves in UCI format."""
return [move.uci() for move in self.board.legal_moves] return [move.uci() for move in self.board.legal_moves]
def get_legal_moves_san(self) -> list[str]: def get_legal_moves_san(self) -> list[str]:
"""Return list of legal moves in SAN format.""" """Return list of legal moves in SAN format."""
return [self.board.san(move) for move in self.board.legal_moves] return [self.board.san(move) for move in self.board.legal_moves]
def _check_game_end(self) -> None: def _check_game_end(self) -> None:
"""Check if the game has ended and set result.""" """Check if the game has ended and set result."""
if self.board.is_checkmate(): if self.board.is_checkmate():
@@ -91,31 +91,31 @@ class ChessGame:
self.result = GameResult.DRAW self.result = GameResult.DRAW
elif self.board.can_claim_draw(): elif self.board.can_claim_draw():
self.result = GameResult.DRAW self.result = GameResult.DRAW
def is_check(self) -> bool: def is_check(self) -> bool:
"""Return True if current player is in check.""" """Return True if current player is in check."""
return self.board.is_check() return self.board.is_check()
def is_game_over(self) -> bool: def is_game_over(self) -> bool:
"""Return True if the game is over.""" """Return True if the game is over."""
return self.result != GameResult.IN_PROGRESS return self.result != GameResult.IN_PROGRESS
def to_pgn(self) -> str: def to_pgn(self) -> str:
"""Export game as PGN string.""" """Export game as PGN string."""
game = chess.pgn.Game() game = chess.pgn.Game()
game.headers["White"] = self.white_player_id game.headers["White"] = self.white_player_id
game.headers["Black"] = self.black_player_id game.headers["Black"] = self.black_player_id
game.headers["Date"] = self.created_at.strftime("%Y.%m.%d") game.headers["Date"] = self.created_at.strftime("%Y.%m.%d")
node = game node = game
temp_board = chess.Board() temp_board = chess.Board()
for move in self.moves: for move in self.moves:
chess_move = chess.Move.from_uci(move.uci) chess_move = chess.Move.from_uci(move.uci)
node = node.add_variation(chess_move) node = node.add_variation(chess_move)
temp_board.push(chess_move) temp_board.push(chess_move)
return str(game) return str(game)
def get_board_display(self, perspective: str = "white") -> str: def get_board_display(self, perspective: str = "white") -> str:
"""Return ASCII board from given perspective.""" """Return ASCII board from given perspective."""
board_str = str(self.board) board_str = str(self.board)

View File

@@ -15,7 +15,7 @@ class PlayerType(Enum):
@dataclass @dataclass
class Player: class Player:
"""Represents a player.""" """Represents a player."""
id: str id: str
username: str username: str
player_type: PlayerType = PlayerType.HUMAN player_type: PlayerType = PlayerType.HUMAN
@@ -26,14 +26,14 @@ class Player:
draws: int = 0 draws: int = 0
created_at: datetime = field(default_factory=datetime.utcnow) created_at: datetime = field(default_factory=datetime.utcnow)
last_seen: Optional[datetime] = None last_seen: Optional[datetime] = None
@property @property
def winrate(self) -> float: def winrate(self) -> float:
"""Calculate win rate percentage.""" """Calculate win rate percentage."""
if self.games_played == 0: if self.games_played == 0:
return 0.0 return 0.0
return (self.wins / self.games_played) * 100 return (self.wins / self.games_played) * 100
def update_elo(self, opponent_elo: int, result: float, k: int = 32) -> int: def update_elo(self, opponent_elo: int, result: float, k: int = 32) -> int:
""" """
Update ELO rating based on game result. Update ELO rating based on game result.
@@ -44,7 +44,7 @@ class Player:
change = int(k * (result - expected)) change = int(k * (result - expected))
self.elo += change self.elo += change
return change return change
def record_game(self, won: bool, draw: bool = False) -> None: def record_game(self, won: bool, draw: bool = False) -> None:
"""Record a completed game.""" """Record a completed game."""
self.games_played += 1 self.games_played += 1
@@ -57,12 +57,12 @@ class Player:
self.last_seen = datetime.utcnow() self.last_seen = datetime.utcnow()
@dataclass @dataclass
class AIPlayer(Player): class AIPlayer(Player):
"""AI player with configurable difficulty.""" """AI player with configurable difficulty."""
difficulty: int = 10 # Stockfish skill level 0-20 difficulty: int = 10 # Stockfish skill level 0-20
think_time_ms: int = 1000 # Time to think per move think_time_ms: int = 1000 # Time to think per move
def __post_init__(self): def __post_init__(self):
self.player_type = PlayerType.AI self.player_type = PlayerType.AI

View File

@@ -14,48 +14,48 @@ logger = logging.getLogger(__name__)
class ShellMateSSHServer(asyncssh.SSHServer): class ShellMateSSHServer(asyncssh.SSHServer):
"""SSH server that launches ShellMate TUI for each connection.""" """SSH server that launches ShellMate TUI for each connection."""
def __init__(self): def __init__(self):
self._username: Optional[str] = None self._username: Optional[str] = None
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None: def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
peername = conn.get_extra_info('peername') peername = conn.get_extra_info('peername')
logger.info(f"SSH connection from {peername}") logger.info(f"SSH connection from {peername}")
def connection_lost(self, exc: Optional[Exception]) -> None: def connection_lost(self, exc: Optional[Exception]) -> None:
if exc: if exc:
logger.error(f"SSH connection error: {exc}") logger.error(f"SSH connection error: {exc}")
else: else:
logger.info("SSH connection closed") logger.info("SSH connection closed")
def begin_auth(self, username: str) -> bool: def begin_auth(self, username: str) -> bool:
self._username = username self._username = username
# No auth required - instant connection # No auth required - instant connection
return False return False
def password_auth_supported(self) -> bool: def password_auth_supported(self) -> bool:
return True return True
def validate_password(self, username: str, password: str) -> bool: def validate_password(self, username: str, password: str) -> bool:
return True return True
def public_key_auth_supported(self) -> bool: def public_key_auth_supported(self) -> bool:
return True return True
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
return True return True
class TerminalSession: class TerminalSession:
"""Manages terminal state for an SSH session.""" """Manages terminal state for an SSH session."""
def __init__(self, process: asyncssh.SSHServerProcess): def __init__(self, process: asyncssh.SSHServerProcess):
self.process = process self.process = process
self.width = 80 self.width = 80
self.height = 24 self.height = 24
self._resize_event = asyncio.Event() self._resize_event = asyncio.Event()
self._update_size() self._update_size()
def _update_size(self): def _update_size(self):
"""Update terminal size from process.""" """Update terminal size from process."""
try: try:
@@ -69,28 +69,28 @@ class TerminalSession:
logger.debug(f"Terminal size updated: {self.width}x{self.height}") logger.debug(f"Terminal size updated: {self.width}x{self.height}")
except Exception as e: except Exception as e:
logger.warning(f"Could not get terminal size: {e}") logger.warning(f"Could not get terminal size: {e}")
def handle_resize(self, width, height, pixwidth, pixheight): def handle_resize(self, width, height, pixwidth, pixheight):
"""Handle terminal resize event.""" """Handle terminal resize event."""
logger.debug(f"Resize event: {width}x{height}") logger.debug(f"Resize event: {width}x{height}")
self.width = max(width, 40) self.width = max(width, 40)
self.height = max(height, 10) self.height = max(height, 10)
self._resize_event.set() self._resize_event.set()
def write(self, data: str): def write(self, data: str):
"""Write string data to terminal.""" """Write string data to terminal."""
if isinstance(data, str): if isinstance(data, str):
data = data.encode('utf-8') data = data.encode('utf-8')
self.process.stdout.write(data) self.process.stdout.write(data)
def clear(self): def clear(self):
"""Clear screen completely and move cursor home.""" """Clear screen completely and move cursor home."""
# Reset scrolling region, clear entire screen, move to home # Reset scrolling region, clear entire screen, move to home
self.write("\033[r\033[2J\033[3J\033[H") self.write("\033[r\033[2J\033[3J\033[H")
def hide_cursor(self): def hide_cursor(self):
self.write("\033[?25l") self.write("\033[?25l")
def show_cursor(self): def show_cursor(self):
self.write("\033[?25h") self.write("\033[?25h")
@@ -98,13 +98,13 @@ class TerminalSession:
async def handle_client(process: asyncssh.SSHServerProcess) -> None: async def handle_client(process: asyncssh.SSHServerProcess) -> None:
"""Handle an SSH client session.""" """Handle an SSH client session."""
username = process.get_extra_info("username") or "guest" username = process.get_extra_info("username") or "guest"
# Create terminal session # Create terminal session
session = TerminalSession(process) session = TerminalSession(process)
term_type = process.get_terminal_type() or "xterm-256color" term_type = process.get_terminal_type() or "xterm-256color"
logger.info(f"Client {username}: term={term_type}, size={session.width}x{session.height}") logger.info(f"Client {username}: term={term_type}, size={session.width}x{session.height}")
# Determine mode # Determine mode
if username == "learn": if username == "learn":
mode = "tutorial" mode = "tutorial"
@@ -112,7 +112,7 @@ async def handle_client(process: asyncssh.SSHServerProcess) -> None:
mode = "spectate" mode = "spectate"
else: else:
mode = "play" mode = "play"
try: try:
session.hide_cursor() session.hide_cursor()
await run_simple_menu(process, session, username, mode) await run_simple_menu(process, session, username, mode)
@@ -139,7 +139,7 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
from rich.layout import Layout from rich.layout import Layout
from rich.style import Style from rich.style import Style
import io import io
class ProcessWriter: class ProcessWriter:
def __init__(self, sess): def __init__(self, sess):
self._session = sess self._session = sess
@@ -147,27 +147,27 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
self._session.write(data) self._session.write(data)
def flush(self): def flush(self):
pass pass
def render_menu(): def render_menu():
"""Render the main menu centered on screen.""" """Render the main menu centered on screen."""
# Re-fetch terminal size before rendering # Re-fetch terminal size before rendering
session._update_size() session._update_size()
writer = ProcessWriter(session) writer = ProcessWriter(session)
console = Console(file=writer, width=session.width, height=session.height, force_terminal=True, color_system="truecolor") console = Console(file=writer, width=session.width, height=session.height, force_terminal=True, color_system="truecolor")
session.clear() session.clear()
# Calculate vertical padding for centering # Calculate vertical padding for centering
menu_height = 22 menu_height = 22
top_pad = max(0, (session.height - menu_height) // 2) top_pad = max(0, (session.height - menu_height) // 2)
for _ in range(top_pad): for _ in range(top_pad):
console.print() console.print()
# Chess piece decorations # Chess piece decorations
pieces = "♔ ♕ ♖ ♗ ♘ ♙" pieces = "♔ ♕ ♖ ♗ ♘ ♙"
# Title with gradient effect # Title with gradient effect
if session.width >= 50: if session.width >= 50:
console.print(Align.center(Text(pieces, style="dim white"))) console.print(Align.center(Text(pieces, style="dim white")))
@@ -178,9 +178,9 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
console.print(Align.center(Text(pieces[::-1], style="dim white"))) console.print(Align.center(Text(pieces[::-1], style="dim white")))
else: else:
console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green"))) console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green")))
console.print() console.print()
# Menu items as a table for better alignment # Menu items as a table for better alignment
menu_table = Table(show_header=False, box=None, padding=(0, 2)) menu_table = Table(show_header=False, box=None, padding=(0, 2))
menu_table.add_column(justify="center") menu_table.add_column(justify="center")
@@ -192,7 +192,7 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]") menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]")
menu_table.add_row("") menu_table.add_row("")
menu_table.add_row(Text("Press a key to select...", style="dim italic")) menu_table.add_row(Text("Press a key to select...", style="dim italic"))
panel_width = min(45, session.width - 4) panel_width = min(45, session.width - 4)
panel = Panel( panel = Panel(
Align.center(menu_table), Align.center(menu_table),
@@ -202,25 +202,25 @@ async def run_simple_menu(process, session: TerminalSession, username: str, mode
padding=(1, 2), padding=(1, 2),
) )
console.print(Align.center(panel)) console.print(Align.center(panel))
# Footer # Footer
console.print() console.print()
console.print(Align.center(Text(f"Terminal: {session.width}×{session.height}", style="dim"))) console.print(Align.center(Text(f"Terminal: {session.width}×{session.height}", style="dim")))
render_menu() render_menu()
# Wait for input # Wait for input
while True: while True:
try: try:
data = await process.stdin.read(1) data = await process.stdin.read(1)
if not data: if not data:
break break
char = data.decode() if isinstance(data, bytes) else data char = data.decode() if isinstance(data, bytes) else data
# Update terminal size in case it changed # Update terminal size in case it changed
session._update_size() session._update_size()
if char in ('q', 'Q', '\x03', '\x04'): # q, Ctrl+C, Ctrl+D if char in ('q', 'Q', '\x03', '\x04'): # q, Ctrl+C, Ctrl+D
session.clear() session.clear()
session.write("\r\n\033[33mGoodbye! Thanks for playing!\033[0m\r\n\r\n") session.write("\r\n\033[33mGoodbye! Thanks for playing!\033[0m\r\n\r\n")
@@ -253,7 +253,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
from rich.align import Align from rich.align import Align
from rich.text import Text from rich.text import Text
from rich.box import ROUNDED from rich.box import ROUNDED
class ProcessWriter: class ProcessWriter:
def __init__(self, sess): def __init__(self, sess):
self._session = sess self._session = sess
@@ -261,11 +261,11 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
self._session.write(data) self._session.write(data)
def flush(self): def flush(self):
pass pass
board = chess.Board() board = chess.Board()
move_history = [] move_history = []
status_msg = "" status_msg = ""
# Try to use Stockfish # Try to use Stockfish
stockfish_engine = None stockfish_engine = None
try: try:
@@ -274,22 +274,22 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
stockfish_engine.set_skill_level(5) # Medium difficulty stockfish_engine.set_skill_level(5) # Medium difficulty
except Exception as e: except Exception as e:
logger.warning(f"Stockfish not available: {e}") logger.warning(f"Stockfish not available: {e}")
# Unicode pieces # Unicode pieces
PIECES = { PIECES = {
'K': '', 'Q': '', 'R': '', 'B': '', 'N': '', 'P': '', 'K': '', 'Q': '', 'R': '', 'B': '', 'N': '', 'P': '',
'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '', 'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '',
} }
# Selection state for two-step moves # Selection state for two-step moves
selected_square = None # None or chess square int selected_square = None # None or chess square int
legal_targets = set() # Set of legal destination squares legal_targets = set() # Set of legal destination squares
def get_cell_style(square, piece, is_light): def get_cell_style(square, piece, is_light):
"""Get ANSI style for a cell based on selection state.""" """Get ANSI style for a cell based on selection state."""
is_selected = (square == selected_square) is_selected = (square == selected_square)
is_target = (square in legal_targets) is_target = (square in legal_targets)
# Background colors # Background colors
if is_selected: if is_selected:
bg = "\033[48;5;33m" # Blue for selected bg = "\033[48;5;33m" # Blue for selected
@@ -299,28 +299,28 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
bg = "" # Default terminal bg = "" # Default terminal
else: else:
bg = "\033[48;5;236m" # Dark grey bg = "\033[48;5;236m" # Dark grey
bg_end = "\033[0m" if bg else "" bg_end = "\033[0m" if bg else ""
return bg, bg_end return bg, bg_end
def render_board(): def render_board():
"""Large, terminal-filling chess board with selection highlighting.""" """Large, terminal-filling chess board with selection highlighting."""
nonlocal status_msg nonlocal status_msg
session._update_size() session._update_size()
session.clear() session.clear()
# Use compact board to fit most terminals # Use compact board to fit most terminals
lines = [] lines = []
# Title # Title
lines.append("") lines.append("")
lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m") lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m")
lines.append("") lines.append("")
# Column labels # Column labels
lines.append(" A B C D E F G H") lines.append(" A B C D E F G H")
lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗") lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗")
for rank in range(7, -1, -1): for rank in range(7, -1, -1):
# Piece row # Piece row
piece_row = f" \033[1;36m{rank + 1}\033[0m ║" piece_row = f" \033[1;36m{rank + 1}\033[0m ║"
@@ -329,7 +329,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
piece = board.piece_at(square) piece = board.piece_at(square)
is_light = (rank + file) % 2 == 1 is_light = (rank + file) % 2 == 1
bg, bg_end = get_cell_style(square, piece, is_light) bg, bg_end = get_cell_style(square, piece, is_light)
if piece: if piece:
char = PIECES.get(piece.symbol(), '?') char = PIECES.get(piece.symbol(), '?')
if piece.color == chess.WHITE: if piece.color == chess.WHITE:
@@ -341,68 +341,68 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
piece_row += f"{bg} \033[1;32m·\033[0m{bg} {bg_end}" piece_row += f"{bg} \033[1;32m·\033[0m{bg} {bg_end}"
else: else:
piece_row += f"{bg} {bg_end}" piece_row += f"{bg} {bg_end}"
if file < 7: if file < 7:
piece_row += "" piece_row += ""
piece_row += f"\033[1;36m{rank + 1}\033[0m" piece_row += f"\033[1;36m{rank + 1}\033[0m"
lines.append(piece_row) lines.append(piece_row)
if rank > 0: if rank > 0:
lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢") lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢")
lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝") lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝")
lines.append(" A B C D E F G H") lines.append(" A B C D E F G H")
board_width = 57 board_width = 57
lines.append("") lines.append("")
# Status # Status
if board.turn == chess.WHITE: if board.turn == chess.WHITE:
lines.append(" \033[1;97mWhite ♔\033[0m to move") lines.append(" \033[1;97mWhite ♔\033[0m to move")
else: else:
lines.append(" \033[1;33mBlack ♚\033[0m to move") lines.append(" \033[1;33mBlack ♚\033[0m to move")
if board.is_check(): if board.is_check():
lines.append(" \033[1;31m⚠ CHECK! ⚠\033[0m") lines.append(" \033[1;31m⚠ CHECK! ⚠\033[0m")
if move_history: if move_history:
last_moves = move_history[-5:] last_moves = move_history[-5:]
lines.append(f" \033[90mRecent: {' '.join(last_moves)}\033[0m") lines.append(f" \033[90mRecent: {' '.join(last_moves)}\033[0m")
if status_msg: if status_msg:
lines.append(f" \033[33m{status_msg}\033[0m") lines.append(f" \033[33m{status_msg}\033[0m")
status_msg = "" status_msg = ""
lines.append("") lines.append("")
# Instructions based on state # Instructions based on state
if selected_square is not None: if selected_square is not None:
sq_name = chess.square_name(selected_square).upper() sq_name = chess.square_name(selected_square).upper()
lines.append(f" \033[36mSelected: {sq_name}\033[0m → Type destination (or ESC to cancel)") lines.append(f" \033[36mSelected: {sq_name}\033[0m → Type destination (or ESC to cancel)")
else: else:
lines.append(" Type \033[36msquare\033[0m (e.g. E2) to select │ \033[31mQ\033[0m = quit") lines.append(" Type \033[36msquare\033[0m (e.g. E2) to select │ \033[31mQ\033[0m = quit")
lines.append("") lines.append("")
# Calculate centering # Calculate centering
total_height = len(lines) total_height = len(lines)
left_pad = max(0, (session.width - board_width) // 2) left_pad = max(0, (session.width - board_width) // 2)
pad = " " * left_pad pad = " " * left_pad
top_pad = max(0, (session.height - total_height) // 2) top_pad = max(0, (session.height - total_height) // 2)
for _ in range(top_pad): for _ in range(top_pad):
session.write("\r\n") session.write("\r\n")
for line in lines: for line in lines:
session.write(pad + line + "\r\n") session.write(pad + line + "\r\n")
# Input prompt # Input prompt
if selected_square is not None: if selected_square is not None:
session.write(pad + f" \033[32m→ \033[0m") session.write(pad + f" \033[32m→ \033[0m")
else: else:
session.write(pad + f" \033[36m> \033[0m") session.write(pad + f" \033[36m> \033[0m")
session.show_cursor() session.show_cursor()
def parse_square(text): def parse_square(text):
"""Parse a square name like 'e2' or 'E2' into a chess square.""" """Parse a square name like 'e2' or 'E2' into a chess square."""
text = text.strip().lower() text = text.strip().lower()
@@ -414,39 +414,39 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
file_idx = ord(file_char) - ord('a') file_idx = ord(file_char) - ord('a')
rank_idx = int(rank_char) - 1 rank_idx = int(rank_char) - 1
return chess.square(file_idx, rank_idx) return chess.square(file_idx, rank_idx)
def select_square(sq): def select_square(sq):
"""Select a square and compute legal moves from it.""" """Select a square and compute legal moves from it."""
nonlocal selected_square, legal_targets nonlocal selected_square, legal_targets
selected_square = sq selected_square = sq
legal_targets = set() legal_targets = set()
piece = board.piece_at(sq) piece = board.piece_at(sq)
if piece and piece.color == board.turn: if piece and piece.color == board.turn:
# Find all legal moves from this square # Find all legal moves from this square
for move in board.legal_moves: for move in board.legal_moves:
if move.from_square == sq: if move.from_square == sq:
legal_targets.add(move.to_square) legal_targets.add(move.to_square)
def clear_selection(): def clear_selection():
"""Clear the current selection.""" """Clear the current selection."""
nonlocal selected_square, legal_targets nonlocal selected_square, legal_targets
selected_square = None selected_square = None
legal_targets = set() legal_targets = set()
render_board() render_board()
input_buffer = "" input_buffer = ""
while not board.is_game_over(): while not board.is_game_over():
try: try:
data = await process.stdin.read(1) data = await process.stdin.read(1)
if not data: if not data:
break break
char = data.decode() if isinstance(data, bytes) else data char = data.decode() if isinstance(data, bytes) else data
session._update_size() session._update_size()
# Quit # Quit
if char.lower() == 'q' and not input_buffer: if char.lower() == 'q' and not input_buffer:
status_msg = "Thanks for playing!" status_msg = "Thanks for playing!"
@@ -454,7 +454,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
render_board() render_board()
await asyncio.sleep(1) await asyncio.sleep(1)
break break
# Escape - cancel selection # Escape - cancel selection
if char == '\x1b': if char == '\x1b':
if selected_square is not None: if selected_square is not None:
@@ -462,24 +462,24 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
render_board() render_board()
input_buffer = "" input_buffer = ""
continue continue
# Ctrl+C/D # Ctrl+C/D
if char in ('\x03', '\x04'): if char in ('\x03', '\x04'):
break break
# Backspace # Backspace
if char == '\x7f' or char == '\b': if char == '\x7f' or char == '\b':
if input_buffer: if input_buffer:
input_buffer = input_buffer[:-1] input_buffer = input_buffer[:-1]
session.write('\b \b') session.write('\b \b')
continue continue
# Enter - process input # Enter - process input
if char in ('\r', '\n'): if char in ('\r', '\n'):
if input_buffer: if input_buffer:
sq = parse_square(input_buffer) sq = parse_square(input_buffer)
input_buffer = "" input_buffer = ""
if sq is not None: if sq is not None:
if selected_square is None: if selected_square is None:
# First click - select piece # First click - select piece
@@ -500,17 +500,17 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
if (piece.color == chess.WHITE and chess.square_rank(sq) == 7) or \ if (piece.color == chess.WHITE and chess.square_rank(sq) == 7) or \
(piece.color == chess.BLACK and chess.square_rank(sq) == 0): (piece.color == chess.BLACK and chess.square_rank(sq) == 0):
move = chess.Move(selected_square, sq, promotion=chess.QUEEN) move = chess.Move(selected_square, sq, promotion=chess.QUEEN)
board.push(move) board.push(move)
move_history.append(move.uci()) move_history.append(move.uci())
clear_selection() clear_selection()
render_board() render_board()
# AI response # AI response
if opponent == "ai" and not board.is_game_over(): if opponent == "ai" and not board.is_game_over():
session.hide_cursor() session.hide_cursor()
session.write("\r\n \033[36mAI thinking...\033[0m") session.write("\r\n \033[36mAI thinking...\033[0m")
if stockfish_engine: if stockfish_engine:
try: try:
stockfish_engine.set_fen_position(board.fen()) stockfish_engine.set_fen_position(board.fen())
@@ -523,7 +523,7 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
import random import random
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
ai_move = random.choice(list(board.legal_moves)) ai_move = random.choice(list(board.legal_moves))
board.push(ai_move) board.push(ai_move)
move_history.append(ai_move.uci()) move_history.append(ai_move.uci())
render_board() render_board()
@@ -545,23 +545,23 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
status_msg = "Invalid square (use a1-h8)" status_msg = "Invalid square (use a1-h8)"
render_board() render_board()
continue continue
# Regular character input # Regular character input
if char.isprintable() and len(input_buffer) < 2: if char.isprintable() and len(input_buffer) < 2:
input_buffer += char input_buffer += char
session.write(char.upper()) session.write(char.upper())
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
logger.error(f"Game input error: {e}") logger.error(f"Game input error: {e}")
continue continue
if board.is_game_over(): if board.is_game_over():
session.clear() session.clear()
writer = ProcessWriter(session) writer = ProcessWriter(session)
console = Console(file=writer, width=session.width, height=session.height, force_terminal=True) console = Console(file=writer, width=session.width, height=session.height, force_terminal=True)
console.print() console.print()
if board.is_checkmate(): if board.is_checkmate():
winner = "Black ♚" if board.turn == chess.WHITE else "White ♔" winner = "Black ♚" if board.turn == chess.WHITE else "White ♔"
@@ -585,19 +585,19 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon
border_style="yellow", border_style="yellow",
width=40 width=40
))) )))
await asyncio.sleep(3) await asyncio.sleep(3)
def ensure_host_key(key_path: str) -> None: def ensure_host_key(key_path: str) -> None:
"""Generate SSH host key if it doesn't exist.""" """Generate SSH host key if it doesn't exist."""
import subprocess import subprocess
if not os.path.exists(key_path): if not os.path.exists(key_path):
logger.info(f"Generating SSH host key at {key_path}") logger.info(f"Generating SSH host key at {key_path}")
os.makedirs(os.path.dirname(key_path), exist_ok=True) os.makedirs(os.path.dirname(key_path), exist_ok=True)
subprocess.run([ subprocess.run([
"ssh-keygen", "-t", "ed25519", "ssh-keygen", "-t", "ed25519",
"-f", key_path, "-N", "" "-f", key_path, "-N", ""
], check=True) ], check=True)
logger.info("SSH host key generated") logger.info("SSH host key generated")
@@ -611,13 +611,13 @@ async def start_server(
"""Start the SSH server.""" """Start the SSH server."""
port = port or int(os.environ.get("SHELLMATE_SSH_PORT", "2222")) port = port or int(os.environ.get("SHELLMATE_SSH_PORT", "2222"))
host_keys = host_keys or ["/etc/shellmate/ssh_host_key"] host_keys = host_keys or ["/etc/shellmate/ssh_host_key"]
# Ensure host key exists (generate if needed) # Ensure host key exists (generate if needed)
for key_path in host_keys: for key_path in host_keys:
ensure_host_key(key_path) ensure_host_key(key_path)
logger.info(f"Starting ShellMate SSH server on {host}:{port}") logger.info(f"Starting ShellMate SSH server on {host}:{port}")
server = await asyncssh.create_server( server = await asyncssh.create_server(
ShellMateSSHServer, ShellMateSSHServer,
host, host,
@@ -626,7 +626,7 @@ async def start_server(
process_factory=handle_client, process_factory=handle_client,
encoding=None, # Binary mode encoding=None, # Binary mode
) )
return server return server
@@ -636,10 +636,10 @@ def main() -> None:
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
) )
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
loop.run_until_complete(start_server()) loop.run_until_complete(start_server())
loop.run_forever() loop.run_forever()

View File

@@ -20,12 +20,12 @@ from shellmate.tui.widgets.move_input import parse_move
class MenuScreen(Screen): class MenuScreen(Screen):
"""Main menu screen.""" """Main menu screen."""
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
yield MainMenu() yield MainMenu()
yield Footer() yield Footer()
def on_main_menu_mode_selected(self, event: MainMenu.ModeSelected) -> None: def on_main_menu_mode_selected(self, event: MainMenu.ModeSelected) -> None:
"""Handle game mode selection.""" """Handle game mode selection."""
if event.mode == "vs_ai": if event.mode == "vs_ai":
@@ -40,7 +40,7 @@ class MenuScreen(Screen):
class GameScreen(Screen): class GameScreen(Screen):
"""Main game screen.""" """Main game screen."""
BINDINGS = [ BINDINGS = [
Binding("escape", "back", "Back to Menu"), Binding("escape", "back", "Back to Menu"),
Binding("f", "flip", "Flip Board"), Binding("f", "flip", "Flip Board"),
@@ -48,7 +48,7 @@ class GameScreen(Screen):
Binding("u", "undo", "Undo"), Binding("u", "undo", "Undo"),
Binding("n", "new_game", "New Game"), Binding("n", "new_game", "New Game"),
] ]
DEFAULT_CSS = """ DEFAULT_CSS = """
GameScreen { GameScreen {
layout: grid; layout: grid;
@@ -57,7 +57,7 @@ class GameScreen(Screen):
grid-rows: 1fr; grid-rows: 1fr;
padding: 0; padding: 0;
} }
#board-container { #board-container {
align: center middle; align: center middle;
padding: 0; padding: 0;
@@ -66,36 +66,36 @@ class GameScreen(Screen):
min-width: 40; min-width: 40;
min-height: 22; min-height: 22;
} }
#chess-board { #chess-board {
width: auto; width: auto;
height: auto; height: auto;
min-width: 36; min-width: 36;
min-height: 20; min-height: 20;
} }
#sidebar { #sidebar {
padding: 1; padding: 1;
min-width: 20; min-width: 20;
max-width: 30; max-width: 30;
border-left: solid #2a3a2a; border-left: solid #2a3a2a;
} }
#game-status { #game-status {
height: auto; height: auto;
max-height: 8; max-height: 8;
} }
#move-list { #move-list {
height: 1fr; height: 1fr;
min-height: 8; min-height: 8;
} }
#move-input { #move-input {
dock: bottom; dock: bottom;
height: auto; height: auto;
} }
.hint-text { .hint-text {
padding: 1; padding: 1;
background: #1a3a1a; background: #1a3a1a;
@@ -103,38 +103,38 @@ class GameScreen(Screen):
margin: 1 0; margin: 1 0;
} }
""" """
def __init__(self, opponent: str = "ai", **kwargs): def __init__(self, opponent: str = "ai", **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.opponent = opponent self.opponent = opponent
self.board = chess.Board() self.board = chess.Board()
self._hint_text: str | None = None self._hint_text: str | None = None
self._ai_engine = None self._ai_engine = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header() yield Header()
with Horizontal(): with Horizontal():
with Container(id="board-container"): with Container(id="board-container"):
yield ChessBoardWidget(board=self.board, id="chess-board") yield ChessBoardWidget(board=self.board, id="chess-board")
with Vertical(id="sidebar"): with Vertical(id="sidebar"):
yield GameStatusWidget(id="game-status") yield GameStatusWidget(id="game-status")
yield MoveListWidget(id="move-list") yield MoveListWidget(id="move-list")
yield MoveInput(id="move-input") yield MoveInput(id="move-input")
yield Footer() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
"""Initialize game on mount.""" """Initialize game on mount."""
status = self.query_one("#game-status", GameStatusWidget) status = self.query_one("#game-status", GameStatusWidget)
status.set_players("You", "Stockfish" if self.opponent == "ai" else "Opponent") status.set_players("You", "Stockfish" if self.opponent == "ai" else "Opponent")
status.update_from_board(self.board) status.update_from_board(self.board)
# Initialize AI if playing against computer # Initialize AI if playing against computer
if self.opponent == "ai": if self.opponent == "ai":
self._init_ai() self._init_ai()
def _init_ai(self) -> None: def _init_ai(self) -> None:
"""Initialize AI engine.""" """Initialize AI engine."""
try: try:
@@ -142,25 +142,25 @@ class GameScreen(Screen):
self._ai_engine = ChessAI(skill_level=10) self._ai_engine = ChessAI(skill_level=10)
except Exception as e: except Exception as e:
self.notify(f"AI unavailable: {e}", severity="warning") self.notify(f"AI unavailable: {e}", severity="warning")
def on_move_input_move_submitted(self, event: MoveInput.MoveSubmitted) -> None: def on_move_input_move_submitted(self, event: MoveInput.MoveSubmitted) -> None:
"""Handle move submission.""" """Handle move submission."""
move = parse_move(self.board, event.move) move = parse_move(self.board, event.move)
if move is None: if move is None:
self.notify(f"Invalid move: {event.move}", severity="error") self.notify(f"Invalid move: {event.move}", severity="error")
return return
self._make_move(move) self._make_move(move)
# AI response # AI response
if self.opponent == "ai" and not self.board.is_game_over() and self._ai_engine: if self.opponent == "ai" and not self.board.is_game_over() and self._ai_engine:
self._ai_move() self._ai_move()
def on_move_input_command_submitted(self, event: MoveInput.CommandSubmitted) -> None: def on_move_input_command_submitted(self, event: MoveInput.CommandSubmitted) -> None:
"""Handle command submission.""" """Handle command submission."""
cmd = event.command.lower() cmd = event.command.lower()
if cmd == "hint": if cmd == "hint":
self.action_hint() self.action_hint()
elif cmd == "resign": elif cmd == "resign":
@@ -174,27 +174,27 @@ class GameScreen(Screen):
self.action_new_game() self.action_new_game()
else: else:
self.notify(f"Unknown command: /{cmd}", severity="warning") self.notify(f"Unknown command: /{cmd}", severity="warning")
def _make_move(self, move: chess.Move) -> None: def _make_move(self, move: chess.Move) -> None:
"""Execute a move on the board.""" """Execute a move on the board."""
# Get SAN before pushing # Get SAN before pushing
san = self.board.san(move) san = self.board.san(move)
# Make the move # Make the move
self.board.push(move) self.board.push(move)
# Update widgets # Update widgets
board_widget = self.query_one("#chess-board", ChessBoardWidget) board_widget = self.query_one("#chess-board", ChessBoardWidget)
board_widget.set_board(self.board) board_widget.set_board(self.board)
board_widget.set_last_move(move) board_widget.set_last_move(move)
board_widget.select_square(None) board_widget.select_square(None)
move_list = self.query_one("#move-list", MoveListWidget) move_list = self.query_one("#move-list", MoveListWidget)
move_list.add_move(san) move_list.add_move(san)
status = self.query_one("#game-status", GameStatusWidget) status = self.query_one("#game-status", GameStatusWidget)
status.update_from_board(self.board) status.update_from_board(self.board)
# Check game end # Check game end
if self.board.is_checkmate(): if self.board.is_checkmate():
winner = "Black" if self.board.turn == chess.WHITE else "White" winner = "Black" if self.board.turn == chess.WHITE else "White"
@@ -203,36 +203,36 @@ class GameScreen(Screen):
self.notify("Stalemate! Game drawn.", severity="information") self.notify("Stalemate! Game drawn.", severity="information")
elif self.board.is_check(): elif self.board.is_check():
self.notify("Check!", severity="warning") self.notify("Check!", severity="warning")
def _ai_move(self) -> None: def _ai_move(self) -> None:
"""Make AI move.""" """Make AI move."""
if not self._ai_engine: if not self._ai_engine:
return return
try: try:
best_move_uci = self._ai_engine.get_best_move(self.board.fen()) best_move_uci = self._ai_engine.get_best_move(self.board.fen())
move = chess.Move.from_uci(best_move_uci) move = chess.Move.from_uci(best_move_uci)
# Small delay for UX # Small delay for UX
self.set_timer(0.5, lambda: self._make_move(move)) self.set_timer(0.5, lambda: self._make_move(move))
# Update evaluation # Update evaluation
analysis = self._ai_engine.analyze_position(self.board.fen(), depth=10) analysis = self._ai_engine.analyze_position(self.board.fen(), depth=10)
status = self.query_one("#game-status", GameStatusWidget) status = self.query_one("#game-status", GameStatusWidget)
status.set_evaluation(analysis.evaluation) status.set_evaluation(analysis.evaluation)
except Exception as e: except Exception as e:
self.notify(f"AI error: {e}", severity="error") self.notify(f"AI error: {e}", severity="error")
def action_back(self) -> None: def action_back(self) -> None:
"""Return to menu.""" """Return to menu."""
self.app.pop_screen() self.app.pop_screen()
def action_flip(self) -> None: def action_flip(self) -> None:
"""Flip the board.""" """Flip the board."""
board_widget = self.query_one("#chess-board", ChessBoardWidget) board_widget = self.query_one("#chess-board", ChessBoardWidget)
board_widget.flip() board_widget.flip()
def action_hint(self) -> None: def action_hint(self) -> None:
"""Get a hint.""" """Get a hint."""
if self._ai_engine and self.board.turn == chess.WHITE: if self._ai_engine and self.board.turn == chess.WHITE:
@@ -240,67 +240,67 @@ class GameScreen(Screen):
self.notify(f"💡 {hint}") self.notify(f"💡 {hint}")
else: else:
self.notify("Hints only available on your turn", severity="warning") self.notify("Hints only available on your turn", severity="warning")
def action_undo(self) -> None: def action_undo(self) -> None:
"""Undo the last move (or last two if vs AI).""" """Undo the last move (or last two if vs AI)."""
if len(self.board.move_stack) == 0: if len(self.board.move_stack) == 0:
self.notify("No moves to undo", severity="warning") self.notify("No moves to undo", severity="warning")
return return
# Undo player move # Undo player move
self.board.pop() self.board.pop()
move_list = self.query_one("#move-list", MoveListWidget) move_list = self.query_one("#move-list", MoveListWidget)
move_list.undo() move_list.undo()
# Also undo AI move if applicable # Also undo AI move if applicable
if self.opponent == "ai" and len(self.board.move_stack) > 0: if self.opponent == "ai" and len(self.board.move_stack) > 0:
self.board.pop() self.board.pop()
move_list.undo() move_list.undo()
# Update display # Update display
board_widget = self.query_one("#chess-board", ChessBoardWidget) board_widget = self.query_one("#chess-board", ChessBoardWidget)
board_widget.set_board(self.board) board_widget.set_board(self.board)
board_widget.set_last_move(None) board_widget.set_last_move(None)
status = self.query_one("#game-status", GameStatusWidget) status = self.query_one("#game-status", GameStatusWidget)
status.update_from_board(self.board) status.update_from_board(self.board)
self.notify("Move undone") self.notify("Move undone")
def action_new_game(self) -> None: def action_new_game(self) -> None:
"""Start a new game.""" """Start a new game."""
self.board = chess.Board() self.board = chess.Board()
board_widget = self.query_one("#chess-board", ChessBoardWidget) board_widget = self.query_one("#chess-board", ChessBoardWidget)
board_widget.set_board(self.board) board_widget.set_board(self.board)
board_widget.set_last_move(None) board_widget.set_last_move(None)
move_list = self.query_one("#move-list", MoveListWidget) move_list = self.query_one("#move-list", MoveListWidget)
move_list.clear() move_list.clear()
status = self.query_one("#game-status", GameStatusWidget) status = self.query_one("#game-status", GameStatusWidget)
status.update_from_board(self.board) status.update_from_board(self.board)
status.set_evaluation(None) status.set_evaluation(None)
self.notify("New game started!") self.notify("New game started!")
class ShellMateApp(App): class ShellMateApp(App):
"""ShellMate TUI Chess Application.""" """ShellMate TUI Chess Application."""
TITLE = "ShellMate" TITLE = "ShellMate"
SUB_TITLE = "SSH into Chess Mastery" SUB_TITLE = "SSH into Chess Mastery"
CSS = """ CSS = """
Screen { Screen {
background: #0a0a0a; background: #0a0a0a;
} }
""" """
BINDINGS = [ BINDINGS = [
Binding("q", "quit", "Quit"), Binding("q", "quit", "Quit"),
] ]
def __init__( def __init__(
self, self,
username: str = "guest", username: str = "guest",
@@ -309,7 +309,7 @@ class ShellMateApp(App):
super().__init__() super().__init__()
self.username = username self.username = username
self.initial_mode = mode self.initial_mode = mode
def on_mount(self) -> None: def on_mount(self) -> None:
"""Start with menu screen.""" """Start with menu screen."""
self.push_screen(MenuScreen()) self.push_screen(MenuScreen())

View File

@@ -8,7 +8,7 @@ from .menu import MainMenu
__all__ = [ __all__ = [
"ChessBoardWidget", "ChessBoardWidget",
"MoveInput", "MoveInput",
"MoveListWidget", "MoveListWidget",
"GameStatusWidget", "GameStatusWidget",
"MainMenu", "MainMenu",

View File

@@ -48,7 +48,7 @@ BOX = {
class ChessBoardWidget(Widget): class ChessBoardWidget(Widget):
"""Interactive chess board widget with auto-sizing.""" """Interactive chess board widget with auto-sizing."""
DEFAULT_CSS = """ DEFAULT_CSS = """
ChessBoardWidget { ChessBoardWidget {
width: 100%; width: 100%;
@@ -58,7 +58,7 @@ class ChessBoardWidget(Widget):
padding: 0; padding: 0;
} }
""" """
# Reactive properties # Reactive properties
selected_square: reactive[int | None] = reactive(None) selected_square: reactive[int | None] = reactive(None)
legal_moves: reactive[set] = reactive(set) legal_moves: reactive[set] = reactive(set)
@@ -66,7 +66,7 @@ class ChessBoardWidget(Widget):
flipped: reactive[bool] = reactive(False) flipped: reactive[bool] = reactive(False)
use_heavy_border: reactive[bool] = reactive(True) use_heavy_border: reactive[bool] = reactive(True)
compact_mode: reactive[bool] = reactive(False) compact_mode: reactive[bool] = reactive(False)
def __init__( def __init__(
self, self,
board: chess.Board | None = None, board: chess.Board | None = None,
@@ -76,83 +76,83 @@ class ChessBoardWidget(Widget):
self.board = board or chess.Board() self.board = board or chess.Board()
self._cell_width = 3 # Will be calculated self._cell_width = 3 # Will be calculated
self._cell_height = 1 self._cell_height = 1
def get_content_width(self, container: Size, viewport: Size) -> int: def get_content_width(self, container: Size, viewport: Size) -> int:
"""Calculate optimal width.""" """Calculate optimal width."""
# Each cell is at least 3 chars wide + borders + labels # Each cell is at least 3 chars wide + borders + labels
min_width = 8 * 3 + 9 + 4 # 8 cells * 3 + 9 borders + labels min_width = 8 * 3 + 9 + 4 # 8 cells * 3 + 9 borders + labels
return max(min_width, container.width) return max(min_width, container.width)
def get_content_height(self, container: Size, viewport: Size) -> int: def get_content_height(self, container: Size, viewport: Size) -> int:
"""Calculate optimal height.""" """Calculate optimal height."""
# Each cell is 1-2 lines + borders + labels # Each cell is 1-2 lines + borders + labels
return max(18, min(22, container.height)) return max(18, min(22, container.height))
def _calculate_cell_size(self, width: int, height: int) -> tuple[int, int]: def _calculate_cell_size(self, width: int, height: int) -> tuple[int, int]:
"""Calculate cell dimensions based on available space.""" """Calculate cell dimensions based on available space."""
# Available width for cells (minus borders and labels) # Available width for cells (minus borders and labels)
available_width = width - 4 # 2 for labels on each side available_width = width - 4 # 2 for labels on each side
available_height = height - 4 # 2 for labels + 2 for borders available_height = height - 4 # 2 for labels + 2 for borders
# Calculate cell width (must be odd for centering) # Calculate cell width (must be odd for centering)
cell_w = max(3, (available_width - 9) // 8) # -9 for internal borders cell_w = max(3, (available_width - 9) // 8) # -9 for internal borders
if cell_w % 2 == 0: if cell_w % 2 == 0:
cell_w -= 1 cell_w -= 1
cell_w = min(cell_w, 7) # Cap at 7 for sanity cell_w = min(cell_w, 7) # Cap at 7 for sanity
# Calculate cell height # Calculate cell height
cell_h = max(1, (available_height - 9) // 8) cell_h = max(1, (available_height - 9) // 8)
cell_h = min(cell_h, 3) cell_h = min(cell_h, 3)
return cell_w, cell_h return cell_w, cell_h
def render(self) -> RenderableType: def render(self) -> RenderableType:
"""Render the chess board with perfect alignment.""" """Render the chess board with perfect alignment."""
size = self.size size = self.size
self._cell_width, self._cell_height = self._calculate_cell_size(size.width, size.height) self._cell_width, self._cell_height = self._calculate_cell_size(size.width, size.height)
# Use compact mode for small terminals # Use compact mode for small terminals
if size.width < 40 or size.height < 20: if size.width < 40 or size.height < 20:
return self._render_compact() return self._render_compact()
else: else:
return self._render_standard() return self._render_standard()
def _render_compact(self) -> RenderableType: def _render_compact(self) -> RenderableType:
"""Compact board for small terminals.""" """Compact board for small terminals."""
text = Text() text = Text()
cw = 3 # Fixed cell width for compact cw = 3 # Fixed cell width for compact
files = "abcdefgh" if not self.flipped else "hgfedcba" files = "abcdefgh" if not self.flipped else "hgfedcba"
# File labels - centered over cells # File labels - centered over cells
text.append(" ", style=f"dim {LABEL_COLOR}") text.append(" ", style=f"dim {LABEL_COLOR}")
for f in files: for f in files:
text.append(f" {f} ", style=f"dim {LABEL_COLOR}") text.append(f" {f} ", style=f"dim {LABEL_COLOR}")
text.append("\n") text.append("\n")
# Top border # Top border
border_style = Style(color=BORDER_COLOR) border_style = Style(color=BORDER_COLOR)
text.append("", style=border_style) text.append("", style=border_style)
text.append("═══╤" * 7 + "═══╗\n", style=border_style) text.append("═══╤" * 7 + "═══╗\n", style=border_style)
ranks = range(7, -1, -1) if not self.flipped else range(8) ranks = range(7, -1, -1) if not self.flipped else range(8)
for rank_idx, rank in enumerate(ranks): for rank_idx, rank in enumerate(ranks):
# Rank label # Rank label
text.append(f"{rank + 1}", style=f"dim {LABEL_COLOR}") text.append(f"{rank + 1}", style=f"dim {LABEL_COLOR}")
file_range = range(8) if not self.flipped else range(7, -1, -1) file_range = range(8) if not self.flipped else range(7, -1, -1)
for file_idx, file in enumerate(file_range): for file_idx, file in enumerate(file_range):
square = chess.square(file, rank) square = chess.square(file, rank)
piece = self.board.piece_at(square) piece = self.board.piece_at(square)
# Determine square styling # Determine square styling
is_light = (rank + file) % 2 == 1 is_light = (rank + file) % 2 == 1
is_selected = square == self.selected_square is_selected = square == self.selected_square
is_legal_target = square in self.legal_moves is_legal_target = square in self.legal_moves
is_last_move = self.last_move and square in self.last_move is_last_move = self.last_move and square in self.last_move
if is_selected: if is_selected:
bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
elif is_last_move: elif is_last_move:
@@ -161,7 +161,7 @@ class ChessBoardWidget(Widget):
bg = HIGHLIGHT_LEGAL bg = HIGHLIGHT_LEGAL
else: else:
bg = LIGHT_SQUARE if is_light else DARK_SQUARE bg = LIGHT_SQUARE if is_light else DARK_SQUARE
# Piece or empty # Piece or empty
if piece: if piece:
char = PIECES_OUTLINE.get(piece.symbol(), '?') char = PIECES_OUTLINE.get(piece.symbol(), '?')
@@ -172,55 +172,55 @@ class ChessBoardWidget(Widget):
else: else:
char = ' ' char = ' '
fg = WHITE_PIECE fg = WHITE_PIECE
style = Style(color=fg, bgcolor=bg, bold=True) style = Style(color=fg, bgcolor=bg, bold=True)
text.append(f" {char} ", style=style) text.append(f" {char} ", style=style)
# Cell separator # Cell separator
if file_idx < 7: if file_idx < 7:
text.append("", style=border_style) text.append("", style=border_style)
text.append(f"{rank + 1}\n", style=f"dim {LABEL_COLOR}") text.append(f"{rank + 1}\n", style=f"dim {LABEL_COLOR}")
# Row separator # Row separator
if rank_idx < 7: if rank_idx < 7:
text.append("", style=border_style) text.append("", style=border_style)
text.append("───┼" * 7 + "───╢\n", style=border_style) text.append("───┼" * 7 + "───╢\n", style=border_style)
# Bottom border # Bottom border
text.append("", style=border_style) text.append("", style=border_style)
text.append("═══╧" * 7 + "═══╝\n", style=border_style) text.append("═══╧" * 7 + "═══╝\n", style=border_style)
# File labels # File labels
text.append(" ", style=f"dim {LABEL_COLOR}") text.append(" ", style=f"dim {LABEL_COLOR}")
for f in files: for f in files:
text.append(f" {f} ", style=f"dim {LABEL_COLOR}") text.append(f" {f} ", style=f"dim {LABEL_COLOR}")
return text return text
def _render_standard(self) -> RenderableType: def _render_standard(self) -> RenderableType:
"""Standard board with variable cell size.""" """Standard board with variable cell size."""
text = Text() text = Text()
cw = self._cell_width cw = self._cell_width
ch = self._cell_height ch = self._cell_height
# Ensure odd width for centering # Ensure odd width for centering
if cw % 2 == 0: if cw % 2 == 0:
cw = max(3, cw - 1) cw = max(3, cw - 1)
files = "abcdefgh" if not self.flipped else "hgfedcba" files = "abcdefgh" if not self.flipped else "hgfedcba"
pad = cw // 2 pad = cw // 2
border_style = Style(color=BORDER_COLOR) border_style = Style(color=BORDER_COLOR)
label_style = Style(color=LABEL_COLOR, dim=True) label_style = Style(color=LABEL_COLOR, dim=True)
# File labels - perfectly centered # File labels - perfectly centered
text.append(" ", style=label_style) text.append(" ", style=label_style)
for f in files: for f in files:
text.append(" " * pad + f + " " * pad, style=label_style) text.append(" " * pad + f + " " * pad, style=label_style)
text.append(" ", style=label_style) # For border space text.append(" ", style=label_style) # For border space
text.append("\n") text.append("\n")
# Top border with double lines # Top border with double lines
cell_border = BOX['H'] * cw cell_border = BOX['H'] * cw
text.append(" " + BOX['TL'], style=border_style) text.append(" " + BOX['TL'], style=border_style)
@@ -229,34 +229,34 @@ class ChessBoardWidget(Widget):
if i < 7: if i < 7:
text.append(BOX['TJ'], style=border_style) text.append(BOX['TJ'], style=border_style)
text.append(BOX['TR'] + "\n", style=border_style) text.append(BOX['TR'] + "\n", style=border_style)
ranks = range(7, -1, -1) if not self.flipped else range(8) ranks = range(7, -1, -1) if not self.flipped else range(8)
for rank_idx, rank in enumerate(ranks): for rank_idx, rank in enumerate(ranks):
# Multi-line cells # Multi-line cells
for cell_line in range(ch): for cell_line in range(ch):
is_middle = cell_line == ch // 2 is_middle = cell_line == ch // 2
# Rank label (only on middle line) # Rank label (only on middle line)
if is_middle: if is_middle:
text.append(f"{rank + 1}", style=label_style) text.append(f"{rank + 1}", style=label_style)
else: else:
text.append(" ", style=label_style) text.append(" ", style=label_style)
text.append(BOX['V'], style=border_style) text.append(BOX['V'], style=border_style)
file_range = range(8) if not self.flipped else range(7, -1, -1) file_range = range(8) if not self.flipped else range(7, -1, -1)
for file_idx, file in enumerate(file_range): for file_idx, file in enumerate(file_range):
square = chess.square(file, rank) square = chess.square(file, rank)
piece = self.board.piece_at(square) piece = self.board.piece_at(square)
# Square styling # Square styling
is_light = (rank + file) % 2 == 1 is_light = (rank + file) % 2 == 1
is_selected = square == self.selected_square is_selected = square == self.selected_square
is_legal_target = square in self.legal_moves is_legal_target = square in self.legal_moves
is_last_move = self.last_move and square in self.last_move is_last_move = self.last_move and square in self.last_move
if is_selected: if is_selected:
bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED bg = LIGHT_SQUARE_SELECTED if is_light else DARK_SQUARE_SELECTED
elif is_last_move: elif is_last_move:
@@ -265,7 +265,7 @@ class ChessBoardWidget(Widget):
bg = HIGHLIGHT_LEGAL bg = HIGHLIGHT_LEGAL
else: else:
bg = LIGHT_SQUARE if is_light else DARK_SQUARE bg = LIGHT_SQUARE if is_light else DARK_SQUARE
# Content (only on middle line) # Content (only on middle line)
if is_middle: if is_middle:
if piece: if piece:
@@ -280,21 +280,21 @@ class ChessBoardWidget(Widget):
else: else:
char = ' ' char = ' '
fg = WHITE_PIECE fg = WHITE_PIECE
style = Style(color=fg, bgcolor=bg, bold=True) style = Style(color=fg, bgcolor=bg, bold=True)
cell_content = " " * pad + char + " " * pad cell_content = " " * pad + char + " " * pad
text.append(cell_content, style=style) text.append(cell_content, style=style)
# Separator # Separator
if file_idx < 7: if file_idx < 7:
text.append(BOX['v'], style=border_style) text.append(BOX['v'], style=border_style)
# Right border and rank label # Right border and rank label
if is_middle: if is_middle:
text.append(f"{BOX['V']}{rank + 1}\n", style=label_style) text.append(f"{BOX['V']}{rank + 1}\n", style=label_style)
else: else:
text.append(f"{BOX['V']} \n", style=border_style) text.append(f"{BOX['V']} \n", style=border_style)
# Row separator # Row separator
if rank_idx < 7: if rank_idx < 7:
text.append(" " + BOX['LJ'], style=border_style) text.append(" " + BOX['LJ'], style=border_style)
@@ -303,7 +303,7 @@ class ChessBoardWidget(Widget):
if i < 7: if i < 7:
text.append(BOX['x'], style=border_style) text.append(BOX['x'], style=border_style)
text.append(BOX['RJ'] + "\n", style=border_style) text.append(BOX['RJ'] + "\n", style=border_style)
# Bottom border # Bottom border
text.append(" " + BOX['BL'], style=border_style) text.append(" " + BOX['BL'], style=border_style)
for i in range(8): for i in range(8):
@@ -311,33 +311,33 @@ class ChessBoardWidget(Widget):
if i < 7: if i < 7:
text.append(BOX['BJ'], style=border_style) text.append(BOX['BJ'], style=border_style)
text.append(BOX['BR'] + "\n", style=border_style) text.append(BOX['BR'] + "\n", style=border_style)
# File labels # File labels
text.append(" ", style=label_style) text.append(" ", style=label_style)
for f in files: for f in files:
text.append(" " * pad + f + " " * pad, style=label_style) text.append(" " * pad + f + " " * pad, style=label_style)
text.append(" ", style=label_style) text.append(" ", style=label_style)
return text return text
def set_board(self, board: chess.Board) -> None: def set_board(self, board: chess.Board) -> None:
"""Update the board state.""" """Update the board state."""
self.board = board self.board = board
self.refresh() self.refresh()
def select_square(self, square: int | None) -> None: def select_square(self, square: int | None) -> None:
"""Select a square and show legal moves from it.""" """Select a square and show legal moves from it."""
self.selected_square = square self.selected_square = square
if square is not None: if square is not None:
self.legal_moves = { self.legal_moves = {
move.to_square move.to_square
for move in self.board.legal_moves for move in self.board.legal_moves
if move.from_square == square if move.from_square == square
} }
else: else:
self.legal_moves = set() self.legal_moves = set()
self.refresh() self.refresh()
def set_last_move(self, move: chess.Move | None) -> None: def set_last_move(self, move: chess.Move | None) -> None:
"""Highlight the last move played.""" """Highlight the last move played."""
if move: if move:
@@ -345,12 +345,12 @@ class ChessBoardWidget(Widget):
else: else:
self.last_move = None self.last_move = None
self.refresh() self.refresh()
def flip(self) -> None: def flip(self) -> None:
"""Flip the board perspective.""" """Flip the board perspective."""
self.flipped = not self.flipped self.flipped = not self.flipped
self.refresh() self.refresh()
def square_from_coords(self, file: str, rank: str) -> int | None: def square_from_coords(self, file: str, rank: str) -> int | None:
"""Convert algebraic notation to square index.""" """Convert algebraic notation to square index."""
try: try:

View File

@@ -10,14 +10,14 @@ from rich.console import RenderableType
class MainMenu(Widget): class MainMenu(Widget):
"""Main menu for game mode selection.""" """Main menu for game mode selection."""
DEFAULT_CSS = """ DEFAULT_CSS = """
MainMenu { MainMenu {
width: 100%; width: 100%;
height: 100%; height: 100%;
align: center middle; align: center middle;
} }
MainMenu > Vertical { MainMenu > Vertical {
width: 50; width: 50;
height: auto; height: auto;
@@ -25,37 +25,37 @@ class MainMenu(Widget):
border: round #333; border: round #333;
background: #111; background: #111;
} }
MainMenu .title { MainMenu .title {
text-align: center; text-align: center;
padding: 1 0 2 0; padding: 1 0 2 0;
} }
MainMenu Button { MainMenu Button {
width: 100%; width: 100%;
margin: 1 0; margin: 1 0;
} }
MainMenu .subtitle { MainMenu .subtitle {
text-align: center; text-align: center;
color: #666; color: #666;
padding: 1 0; padding: 1 0;
} }
""" """
class ModeSelected(Message): class ModeSelected(Message):
"""Emitted when a game mode is selected.""" """Emitted when a game mode is selected."""
def __init__(self, mode: str) -> None: def __init__(self, mode: str) -> None:
super().__init__() super().__init__()
self.mode = mode self.mode = mode
LOGO = """ LOGO = """
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ ♟️ S H E L L M A T E │ │ ♟️ S H E L L M A T E │
│ SSH into Chess Mastery │ │ SSH into Chess Mastery │
└─────────────────────────────┘ └─────────────────────────────┘
""" """
def compose(self): def compose(self):
with Vertical(): with Vertical():
yield Static(self.LOGO, classes="title") yield Static(self.LOGO, classes="title")
@@ -65,7 +65,7 @@ class MainMenu(Widget):
yield Button("📚 Learn Chess", id="btn-learn", variant="default") yield Button("📚 Learn Chess", id="btn-learn", variant="default")
yield Button("👀 Spectate", id="btn-watch", variant="default") yield Button("👀 Spectate", id="btn-watch", variant="default")
yield Static("Press Q to quit", classes="subtitle") yield Static("Press Q to quit", classes="subtitle")
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button press.""" """Handle button press."""
mode_map = { mode_map = {

View File

@@ -7,7 +7,7 @@ import chess
class MoveInput(Input): class MoveInput(Input):
"""Input widget for chess moves.""" """Input widget for chess moves."""
DEFAULT_CSS = """ DEFAULT_CSS = """
MoveInput { MoveInput {
dock: bottom; dock: bottom;
@@ -15,46 +15,46 @@ class MoveInput(Input):
margin: 1 0; margin: 1 0;
} }
""" """
class MoveSubmitted(Message): class MoveSubmitted(Message):
"""Message sent when a move is submitted.""" """Message sent when a move is submitted."""
def __init__(self, move: str) -> None: def __init__(self, move: str) -> None:
super().__init__() super().__init__()
self.move = move self.move = move
class CommandSubmitted(Message): class CommandSubmitted(Message):
"""Message sent when a command is submitted.""" """Message sent when a command is submitted."""
def __init__(self, command: str) -> None: def __init__(self, command: str) -> None:
super().__init__() super().__init__()
self.command = command self.command = command
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__( super().__init__(
placeholder="Enter move (e.g., e4, Nf3, O-O) or command (/hint, /resign)", placeholder="Enter move (e.g., e4, Nf3, O-O) or command (/hint, /resign)",
**kwargs **kwargs
) )
def on_input_submitted(self, event: Input.Submitted) -> None: def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle input submission.""" """Handle input submission."""
value = event.value.strip() value = event.value.strip()
if not value: if not value:
return return
if value.startswith('/'): if value.startswith('/'):
# It's a command # It's a command
self.post_message(self.CommandSubmitted(value[1:])) self.post_message(self.CommandSubmitted(value[1:]))
else: else:
# It's a move # It's a move
self.post_message(self.MoveSubmitted(value)) self.post_message(self.MoveSubmitted(value))
self.value = "" self.value = ""
def parse_move(board: chess.Board, move_str: str) -> chess.Move | None: def parse_move(board: chess.Board, move_str: str) -> chess.Move | None:
"""Parse a move string into a chess.Move object.""" """Parse a move string into a chess.Move object."""
move_str = move_str.strip() move_str = move_str.strip()
# Try UCI format first (e2e4) # Try UCI format first (e2e4)
try: try:
move = chess.Move.from_uci(move_str) move = chess.Move.from_uci(move_str)
@@ -62,7 +62,7 @@ def parse_move(board: chess.Board, move_str: str) -> chess.Move | None:
return move return move
except ValueError: except ValueError:
pass pass
# Try SAN format (e4, Nf3, O-O) # Try SAN format (e4, Nf3, O-O)
try: try:
move = board.parse_san(move_str) move = board.parse_san(move_str)
@@ -70,5 +70,5 @@ def parse_move(board: chess.Board, move_str: str) -> chess.Move | None:
return move return move
except ValueError: except ValueError:
pass pass
return None return None

View File

@@ -9,7 +9,7 @@ import chess
class MoveListWidget(Widget): class MoveListWidget(Widget):
"""Display the move history in standard notation.""" """Display the move history in standard notation."""
DEFAULT_CSS = """ DEFAULT_CSS = """
MoveListWidget { MoveListWidget {
width: 100%; width: 100%;
@@ -19,47 +19,47 @@ class MoveListWidget(Widget):
border: solid #333; border: solid #333;
} }
""" """
moves: reactive[list] = reactive(list, always_update=True) moves: reactive[list] = reactive(list, always_update=True)
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._moves: list[str] = [] self._moves: list[str] = []
def render(self) -> RenderableType: def render(self) -> RenderableType:
"""Render the move list.""" """Render the move list."""
text = Text() text = Text()
text.append("Move History\n", style="bold underline") text.append("Move History\n", style="bold underline")
text.append("" * 20 + "\n", style="dim") text.append("" * 20 + "\n", style="dim")
if not self._moves: if not self._moves:
text.append("No moves yet", style="dim italic") text.append("No moves yet", style="dim italic")
return text return text
# Display moves in pairs (white, black) # Display moves in pairs (white, black)
for i in range(0, len(self._moves), 2): for i in range(0, len(self._moves), 2):
move_num = i // 2 + 1 move_num = i // 2 + 1
white_move = self._moves[i] white_move = self._moves[i]
black_move = self._moves[i + 1] if i + 1 < len(self._moves) else "" black_move = self._moves[i + 1] if i + 1 < len(self._moves) else ""
text.append(f"{move_num:>3}. ", style="dim") text.append(f"{move_num:>3}. ", style="dim")
text.append(f"{white_move:<8}", style="bold white") text.append(f"{white_move:<8}", style="bold white")
if black_move: if black_move:
text.append(f"{black_move:<8}", style="bold green") text.append(f"{black_move:<8}", style="bold green")
text.append("\n") text.append("\n")
return text return text
def add_move(self, move_san: str) -> None: def add_move(self, move_san: str) -> None:
"""Add a move to the history.""" """Add a move to the history."""
self._moves.append(move_san) self._moves.append(move_san)
self.refresh() self.refresh()
def clear(self) -> None: def clear(self) -> None:
"""Clear the move history.""" """Clear the move history."""
self._moves = [] self._moves = []
self.refresh() self.refresh()
def undo(self) -> str | None: def undo(self) -> str | None:
"""Remove and return the last move.""" """Remove and return the last move."""
if self._moves: if self._moves:
@@ -67,7 +67,7 @@ class MoveListWidget(Widget):
self.refresh() self.refresh()
return move return move
return None return None
def get_pgn_moves(self) -> str: def get_pgn_moves(self) -> str:
"""Get moves in PGN format.""" """Get moves in PGN format."""
result = [] result = []

View File

@@ -10,7 +10,7 @@ import chess
class GameStatusWidget(Widget): class GameStatusWidget(Widget):
"""Display current game status.""" """Display current game status."""
DEFAULT_CSS = """ DEFAULT_CSS = """
GameStatusWidget { GameStatusWidget {
width: 100%; width: 100%;
@@ -19,7 +19,7 @@ class GameStatusWidget(Widget):
border: solid #333; border: solid #333;
} }
""" """
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._turn = chess.WHITE self._turn = chess.WHITE
@@ -32,15 +32,15 @@ class GameStatusWidget(Widget):
self._white_time: int | None = None # seconds self._white_time: int | None = None # seconds
self._black_time: int | None = None self._black_time: int | None = None
self._evaluation: float | None = None self._evaluation: float | None = None
def render(self) -> RenderableType: def render(self) -> RenderableType:
"""Render the status panel.""" """Render the status panel."""
text = Text() text = Text()
# Player indicator # Player indicator
turn_indicator = "" if self._turn == chess.WHITE else "" turn_indicator = "" if self._turn == chess.WHITE else ""
turn_name = self._white_name if self._turn == chess.WHITE else self._black_name turn_name = self._white_name if self._turn == chess.WHITE else self._black_name
if self._is_checkmate: if self._is_checkmate:
winner = self._black_name if self._turn == chess.WHITE else self._white_name winner = self._black_name if self._turn == chess.WHITE else self._white_name
text.append(f"♚ CHECKMATE!\n", style="bold red") text.append(f"♚ CHECKMATE!\n", style="bold red")
@@ -55,20 +55,20 @@ class GameStatusWidget(Widget):
text.append(f"{turn_indicator} {turn_name}'s turn\n", style="bold") text.append(f"{turn_indicator} {turn_name}'s turn\n", style="bold")
if self._is_check: if self._is_check:
text.append("⚠ CHECK!\n", style="bold red") text.append("⚠ CHECK!\n", style="bold red")
# Time controls (if set) # Time controls (if set)
if self._white_time is not None or self._black_time is not None: if self._white_time is not None or self._black_time is not None:
text.append("\n") text.append("\n")
white_time_str = self._format_time(self._white_time) white_time_str = self._format_time(self._white_time)
black_time_str = self._format_time(self._black_time) black_time_str = self._format_time(self._black_time)
white_style = "bold" if self._turn == chess.WHITE else "dim" white_style = "bold" if self._turn == chess.WHITE else "dim"
black_style = "bold" if self._turn == chess.BLACK else "dim" black_style = "bold" if self._turn == chess.BLACK else "dim"
text.append(f"{white_time_str}", style=white_style) text.append(f"{white_time_str}", style=white_style)
text.append("", style="dim") text.append("", style="dim")
text.append(f"{black_time_str}", style=black_style) text.append(f"{black_time_str}", style=black_style)
# Evaluation (if available) # Evaluation (if available)
if self._evaluation is not None and not (self._is_checkmate or self._is_stalemate or self._is_draw): if self._evaluation is not None and not (self._is_checkmate or self._is_stalemate or self._is_draw):
text.append("\n\n") text.append("\n\n")
@@ -76,9 +76,9 @@ class GameStatusWidget(Widget):
bar = self._eval_bar(self._evaluation) bar = self._eval_bar(self._evaluation)
text.append(f"Eval: {eval_str}\n", style="dim") text.append(f"Eval: {eval_str}\n", style="dim")
text.append(bar) text.append(bar)
return text return text
def _format_time(self, seconds: int | None) -> str: def _format_time(self, seconds: int | None) -> str:
"""Format seconds as MM:SS.""" """Format seconds as MM:SS."""
if seconds is None: if seconds is None:
@@ -86,24 +86,24 @@ class GameStatusWidget(Widget):
mins = seconds // 60 mins = seconds // 60
secs = seconds % 60 secs = seconds % 60
return f"{mins:02d}:{secs:02d}" return f"{mins:02d}:{secs:02d}"
def _eval_bar(self, evaluation: float) -> Text: def _eval_bar(self, evaluation: float) -> Text:
"""Create a visual evaluation bar.""" """Create a visual evaluation bar."""
text = Text() text = Text()
bar_width = 20 bar_width = 20
# Clamp evaluation to -5 to +5 for display # Clamp evaluation to -5 to +5 for display
clamped = max(-5, min(5, evaluation)) clamped = max(-5, min(5, evaluation))
# Convert to 0-1 scale (0.5 = equal) # Convert to 0-1 scale (0.5 = equal)
normalized = (clamped + 5) / 10 normalized = (clamped + 5) / 10
white_chars = int(normalized * bar_width) white_chars = int(normalized * bar_width)
black_chars = bar_width - white_chars black_chars = bar_width - white_chars
text.append("" * white_chars, style="white") text.append("" * white_chars, style="white")
text.append("" * black_chars, style="green") text.append("" * black_chars, style="green")
return text return text
def update_from_board(self, board: chess.Board) -> None: def update_from_board(self, board: chess.Board) -> None:
"""Update status from a board state.""" """Update status from a board state."""
self._turn = board.turn self._turn = board.turn
@@ -112,19 +112,19 @@ class GameStatusWidget(Widget):
self._is_stalemate = board.is_stalemate() self._is_stalemate = board.is_stalemate()
self._is_draw = board.is_insufficient_material() or board.can_claim_draw() self._is_draw = board.is_insufficient_material() or board.can_claim_draw()
self.refresh() self.refresh()
def set_players(self, white: str, black: str) -> None: def set_players(self, white: str, black: str) -> None:
"""Set player names.""" """Set player names."""
self._white_name = white self._white_name = white
self._black_name = black self._black_name = black
self.refresh() self.refresh()
def set_time(self, white_seconds: int | None, black_seconds: int | None) -> None: def set_time(self, white_seconds: int | None, black_seconds: int | None) -> None:
"""Set time remaining.""" """Set time remaining."""
self._white_time = white_seconds self._white_time = white_seconds
self._black_time = black_seconds self._black_time = black_seconds
self.refresh() self.refresh()
def set_evaluation(self, evaluation: float | None) -> None: def set_evaluation(self, evaluation: float | None) -> None:
"""Set position evaluation.""" """Set position evaluation."""
self._evaluation = evaluation self._evaluation = evaluation

View File

@@ -7,46 +7,46 @@ from unittest.mock import AsyncMock, MagicMock, patch
class TestShellMateSSHServer: class TestShellMateSSHServer:
"""Test SSH server authentication.""" """Test SSH server authentication."""
def test_begin_auth_returns_false(self): def test_begin_auth_returns_false(self):
"""Auth should complete immediately (no auth required).""" """Auth should complete immediately (no auth required)."""
from shellmate.ssh.server import ShellMateSSHServer from shellmate.ssh.server import ShellMateSSHServer
server = ShellMateSSHServer() server = ShellMateSSHServer()
result = server.begin_auth("anyuser") result = server.begin_auth("anyuser")
assert result is False, "begin_auth should return False for no-auth" assert result is False, "begin_auth should return False for no-auth"
def test_password_auth_accepts_any(self): def test_password_auth_accepts_any(self):
"""Any password should be accepted.""" """Any password should be accepted."""
from shellmate.ssh.server import ShellMateSSHServer from shellmate.ssh.server import ShellMateSSHServer
server = ShellMateSSHServer() server = ShellMateSSHServer()
assert server.password_auth_supported() is True assert server.password_auth_supported() is True
assert server.validate_password("user", "") is True assert server.validate_password("user", "") is True
assert server.validate_password("user", "anypass") is True assert server.validate_password("user", "anypass") is True
assert server.validate_password("guest", "password123") is True assert server.validate_password("guest", "password123") is True
def test_pubkey_auth_accepts_any(self): def test_pubkey_auth_accepts_any(self):
"""Any public key should be accepted.""" """Any public key should be accepted."""
from shellmate.ssh.server import ShellMateSSHServer from shellmate.ssh.server import ShellMateSSHServer
server = ShellMateSSHServer() server = ShellMateSSHServer()
mock_key = MagicMock() mock_key = MagicMock()
assert server.public_key_auth_supported() is True assert server.public_key_auth_supported() is True
assert server.validate_public_key("user", mock_key) is True assert server.validate_public_key("user", mock_key) is True
class TestModeSelection: class TestModeSelection:
"""Test username-to-mode mapping.""" """Test username-to-mode mapping."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_play_mode_default(self): async def test_play_mode_default(self):
"""Default users should get play mode.""" """Default users should get play mode."""
from shellmate.ssh.server import handle_client from shellmate.ssh.server import handle_client
process = MagicMock() process = MagicMock()
process.get_extra_info = MagicMock(return_value="greg") process.get_extra_info = MagicMock(return_value="greg")
process.get_terminal_type = MagicMock(return_value="xterm-256color") process.get_terminal_type = MagicMock(return_value="xterm-256color")
@@ -54,24 +54,24 @@ class TestModeSelection:
process.stdin = AsyncMock() process.stdin = AsyncMock()
process.stdout = MagicMock() process.stdout = MagicMock()
process.exit = MagicMock() process.exit = MagicMock()
# Mock stdin to return quit immediately # Mock stdin to return quit immediately
process.stdin.read = AsyncMock(return_value=b'q') process.stdin.read = AsyncMock(return_value=b'q')
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu: with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
mock_menu.return_value = None mock_menu.return_value = None
await handle_client(process) await handle_client(process)
# Verify mode was 'play' # Verify mode was 'play'
mock_menu.assert_called_once() mock_menu.assert_called_once()
call_args = mock_menu.call_args call_args = mock_menu.call_args
assert call_args[0][2] == "play" # mode argument assert call_args[0][2] == "play" # mode argument
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_learn_mode(self): async def test_learn_mode(self):
"""Username 'learn' should get tutorial mode.""" """Username 'learn' should get tutorial mode."""
from shellmate.ssh.server import handle_client from shellmate.ssh.server import handle_client
process = MagicMock() process = MagicMock()
process.get_extra_info = MagicMock(return_value="learn") process.get_extra_info = MagicMock(return_value="learn")
process.get_terminal_type = MagicMock(return_value="xterm") process.get_terminal_type = MagicMock(return_value="xterm")
@@ -80,18 +80,18 @@ class TestModeSelection:
process.stdout = MagicMock() process.stdout = MagicMock()
process.exit = MagicMock() process.exit = MagicMock()
process.stdin.read = AsyncMock(return_value=b'q') process.stdin.read = AsyncMock(return_value=b'q')
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu: with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
await handle_client(process) await handle_client(process)
call_args = mock_menu.call_args call_args = mock_menu.call_args
assert call_args[0][2] == "tutorial" assert call_args[0][2] == "tutorial"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_watch_mode(self): async def test_watch_mode(self):
"""Username 'watch' should get spectate mode.""" """Username 'watch' should get spectate mode."""
from shellmate.ssh.server import handle_client from shellmate.ssh.server import handle_client
process = MagicMock() process = MagicMock()
process.get_extra_info = MagicMock(return_value="watch") process.get_extra_info = MagicMock(return_value="watch")
process.get_terminal_type = MagicMock(return_value="xterm") process.get_terminal_type = MagicMock(return_value="xterm")
@@ -100,100 +100,100 @@ class TestModeSelection:
process.stdout = MagicMock() process.stdout = MagicMock()
process.exit = MagicMock() process.exit = MagicMock()
process.stdin.read = AsyncMock(return_value=b'q') process.stdin.read = AsyncMock(return_value=b'q')
with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu: with patch('shellmate.ssh.server.run_simple_menu', new_callable=AsyncMock) as mock_menu:
await handle_client(process) await handle_client(process)
call_args = mock_menu.call_args call_args = mock_menu.call_args
assert call_args[0][2] == "spectate" assert call_args[0][2] == "spectate"
class TestChessBoard: class TestChessBoard:
"""Test chess board widget rendering.""" """Test chess board widget rendering."""
def test_board_initialization(self): def test_board_initialization(self):
"""Board should initialize with standard position.""" """Board should initialize with standard position."""
from shellmate.tui.widgets.board import ChessBoardWidget from shellmate.tui.widgets.board import ChessBoardWidget
import chess import chess
widget = ChessBoardWidget() widget = ChessBoardWidget()
assert widget.board.fen() == chess.STARTING_FEN assert widget.board.fen() == chess.STARTING_FEN
assert widget.selected_square is None assert widget.selected_square is None
assert widget.flipped is False assert widget.flipped is False
def test_board_flip(self): def test_board_flip(self):
"""Board flip should toggle perspective.""" """Board flip should toggle perspective."""
from shellmate.tui.widgets.board import ChessBoardWidget from shellmate.tui.widgets.board import ChessBoardWidget
widget = ChessBoardWidget() widget = ChessBoardWidget()
assert widget.flipped is False assert widget.flipped is False
widget.flip() widget.flip()
assert widget.flipped is True assert widget.flipped is True
widget.flip() widget.flip()
assert widget.flipped is False assert widget.flipped is False
def test_square_selection(self): def test_square_selection(self):
"""Selecting a square should show legal moves.""" """Selecting a square should show legal moves."""
from shellmate.tui.widgets.board import ChessBoardWidget from shellmate.tui.widgets.board import ChessBoardWidget
import chess import chess
widget = ChessBoardWidget() widget = ChessBoardWidget()
# Select e2 pawn # Select e2 pawn
e2 = chess.E2 e2 = chess.E2
widget.select_square(e2) widget.select_square(e2)
assert widget.selected_square == e2 assert widget.selected_square == e2
assert chess.E3 in widget.legal_moves assert chess.E3 in widget.legal_moves
assert chess.E4 in widget.legal_moves assert chess.E4 in widget.legal_moves
assert len(widget.legal_moves) == 2 # e3 and e4 assert len(widget.legal_moves) == 2 # e3 and e4
def test_square_deselection(self): def test_square_deselection(self):
"""Deselecting should clear legal moves.""" """Deselecting should clear legal moves."""
from shellmate.tui.widgets.board import ChessBoardWidget from shellmate.tui.widgets.board import ChessBoardWidget
import chess import chess
widget = ChessBoardWidget() widget = ChessBoardWidget()
widget.select_square(chess.E2) widget.select_square(chess.E2)
widget.select_square(None) widget.select_square(None)
assert widget.selected_square is None assert widget.selected_square is None
assert len(widget.legal_moves) == 0 assert len(widget.legal_moves) == 0
class TestMoveValidation: class TestMoveValidation:
"""Test move parsing and validation.""" """Test move parsing and validation."""
def test_valid_uci_move(self): def test_valid_uci_move(self):
"""Valid UCI moves should be accepted.""" """Valid UCI moves should be accepted."""
import chess import chess
board = chess.Board() board = chess.Board()
move = chess.Move.from_uci("e2e4") move = chess.Move.from_uci("e2e4")
assert move in board.legal_moves assert move in board.legal_moves
def test_invalid_uci_move(self): def test_invalid_uci_move(self):
"""Invalid UCI moves should be rejected.""" """Invalid UCI moves should be rejected."""
import chess import chess
board = chess.Board() board = chess.Board()
move = chess.Move.from_uci("e2e5") # Invalid - pawn can't go there move = chess.Move.from_uci("e2e5") # Invalid - pawn can't go there
assert move not in board.legal_moves assert move not in board.legal_moves
def test_algebraic_to_uci(self): def test_algebraic_to_uci(self):
"""Test converting algebraic notation.""" """Test converting algebraic notation."""
import chess import chess
board = chess.Board() board = chess.Board()
# e4 in algebraic # e4 in algebraic
move = board.parse_san("e4") move = board.parse_san("e4")
assert move.uci() == "e2e4" assert move.uci() == "e2e4"
# Nf3 in algebraic # Nf3 in algebraic
board.push_san("e4") board.push_san("e4")
board.push_san("e5") board.push_san("e5")
@@ -203,77 +203,77 @@ class TestMoveValidation:
class TestGameState: class TestGameState:
"""Test game state detection.""" """Test game state detection."""
def test_checkmate_detection(self): def test_checkmate_detection(self):
"""Checkmate should be detected.""" """Checkmate should be detected."""
import chess import chess
# Fool's mate # Fool's mate
board = chess.Board() board = chess.Board()
board.push_san("f3") board.push_san("f3")
board.push_san("e5") board.push_san("e5")
board.push_san("g4") board.push_san("g4")
board.push_san("Qh4") board.push_san("Qh4")
assert board.is_checkmate() assert board.is_checkmate()
assert board.is_game_over() assert board.is_game_over()
def test_stalemate_detection(self): def test_stalemate_detection(self):
"""Stalemate should be detected.""" """Stalemate should be detected."""
import chess import chess
# Set up a stalemate position # Set up a stalemate position
board = chess.Board("k7/8/1K6/8/8/8/8/8 b - - 0 1") board = chess.Board("k7/8/1K6/8/8/8/8/8 b - - 0 1")
# This isn't stalemate, let's use a real one # This isn't stalemate, let's use a real one
board = chess.Board("k7/8/8/8/8/8/1R6/K7 b - - 0 1") board = chess.Board("k7/8/8/8/8/8/1R6/K7 b - - 0 1")
# Actually, let's just check the method exists # Actually, let's just check the method exists
assert hasattr(board, 'is_stalemate') assert hasattr(board, 'is_stalemate')
def test_check_detection(self): def test_check_detection(self):
"""Check should be detected.""" """Check should be detected."""
import chess import chess
board = chess.Board() board = chess.Board()
board.push_san("e4") board.push_san("e4")
board.push_san("e5") board.push_san("e5")
board.push_san("Qh5") board.push_san("Qh5")
board.push_san("Nc6") board.push_san("Nc6")
board.push_san("Qxf7") board.push_san("Qxf7")
assert board.is_check() assert board.is_check()
# Integration tests # Integration tests
class TestIntegration: class TestIntegration:
"""Integration tests for full flow.""" """Integration tests for full flow."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_server_starts(self): async def test_server_starts(self):
"""Server should start without errors.""" """Server should start without errors."""
from shellmate.ssh.server import start_server from shellmate.ssh.server import start_server
import tempfile import tempfile
import os import os
# Create a temporary host key # Create a temporary host key
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
key_path = os.path.join(tmpdir, "test_key") key_path = os.path.join(tmpdir, "test_key")
# Generate a key # Generate a key
import subprocess import subprocess
subprocess.run([ subprocess.run([
"ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", "" "ssh-keygen", "-t", "ed25519", "-f", key_path, "-N", ""
], check=True, capture_output=True) ], check=True, capture_output=True)
# Start server on a random port # Start server on a random port
server = await start_server( server = await start_server(
host="127.0.0.1", host="127.0.0.1",
port=0, # Random available port port=0, # Random available port
host_keys=[key_path], host_keys=[key_path],
) )
assert server is not None assert server is not None
# Clean up # Clean up
server.close() server.close()
await server.wait_closed() await server.wait_closed()

View File

@@ -15,12 +15,12 @@ def test_menu_render_no_markup_errors():
# Simulate the menu rendering code # Simulate the menu rendering code
output = StringIO() output = StringIO()
console = Console(file=output, width=80, height=24, force_terminal=True, color_system="truecolor") console = Console(file=output, width=80, height=24, force_terminal=True, color_system="truecolor")
username = "testuser" username = "testuser"
width = 80 width = 80
height = 24 height = 24
pieces = "♔ ♕ ♖ ♗ ♘ ♙" pieces = "♔ ♕ ♖ ♗ ♘ ♙"
# Title section # Title section
console.print(Align.center(Text(pieces, style="dim white"))) console.print(Align.center(Text(pieces, style="dim white")))
console.print() console.print()
@@ -29,7 +29,7 @@ def test_menu_render_no_markup_errors():
console.print(Align.center(Text("SSH into Chess Mastery", style="italic bright_black"))) console.print(Align.center(Text("SSH into Chess Mastery", style="italic bright_black")))
console.print(Align.center(Text(pieces[::-1], style="dim white"))) console.print(Align.center(Text(pieces[::-1], style="dim white")))
console.print() console.print()
# Menu table - this is where markup errors often occur # Menu table - this is where markup errors often occur
menu_table = Table(show_header=False, box=None, padding=(0, 2)) menu_table = Table(show_header=False, box=None, padding=(0, 2))
menu_table.add_column(justify="center") menu_table.add_column(justify="center")
@@ -41,7 +41,7 @@ def test_menu_render_no_markup_errors():
menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]") menu_table.add_row("[bright_white on red] q [/bright_white on red] Quit [dim]👋[/dim]")
menu_table.add_row("") menu_table.add_row("")
menu_table.add_row(Text("Press a key to select...", style="dim italic")) menu_table.add_row(Text("Press a key to select...", style="dim italic"))
panel_width = min(45, width - 4) panel_width = min(45, width - 4)
panel = Panel( panel = Panel(
Align.center(menu_table), Align.center(menu_table),
@@ -51,11 +51,11 @@ def test_menu_render_no_markup_errors():
padding=(1, 2), padding=(1, 2),
) )
console.print(Align.center(panel)) console.print(Align.center(panel))
# Footer # Footer
console.print() console.print()
console.print(Align.center(Text(f"Terminal: {width}×{height}", style="dim"))) console.print(Align.center(Text(f"Terminal: {width}×{height}", style="dim")))
# If we got here without exception, markup is valid # If we got here without exception, markup is valid
rendered = output.getvalue() rendered = output.getvalue()
# Check key content is present (content may have ANSI codes) # Check key content is present (content may have ANSI codes)
@@ -68,13 +68,13 @@ def test_game_status_render():
"""Test that game status panel renders correctly.""" """Test that game status panel renders correctly."""
output = StringIO() output = StringIO()
console = Console(file=output, width=80, height=24, force_terminal=True) console = Console(file=output, width=80, height=24, force_terminal=True)
status_lines = [] status_lines = []
status_lines.append("[bold white]White ♔ to move[/bold white]") status_lines.append("[bold white]White ♔ to move[/bold white]")
status_lines.append("[dim]Moves: e2e4 e7e5 g1f3[/dim]") status_lines.append("[dim]Moves: e2e4 e7e5 g1f3[/dim]")
status_lines.append("") status_lines.append("")
status_lines.append("[dim]Enter move (e.g. e2e4) │ [q]uit │ [r]esign[/dim]") status_lines.append("[dim]Enter move (e.g. e2e4) │ [q]uit │ [r]esign[/dim]")
panel = Panel( panel = Panel(
"\n".join(status_lines), "\n".join(status_lines),
box=ROUNDED, box=ROUNDED,
@@ -83,7 +83,7 @@ def test_game_status_render():
title="[bold]Game Status[/bold]" title="[bold]Game Status[/bold]"
) )
console.print(panel) console.print(panel)
rendered = output.getvalue() rendered = output.getvalue()
assert "White" in rendered assert "White" in rendered
assert "Game Status" in rendered assert "Game Status" in rendered
@@ -92,17 +92,17 @@ def test_game_status_render():
def test_chess_board_render(): def test_chess_board_render():
"""Test that chess board renders without errors.""" """Test that chess board renders without errors."""
output = StringIO() output = StringIO()
PIECES = { PIECES = {
'K': '', 'Q': '', 'R': '', 'B': '', 'N': '', 'P': '', 'K': '', 'Q': '', 'R': '', 'B': '', 'N': '', 'P': '',
'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '', 'k': '', 'q': '', 'r': '', 'b': '', 'n': '', 'p': '',
} }
# Simplified board rendering (plain text) # Simplified board rendering (plain text)
lines = [] lines = []
lines.append(" a b c d e f g h") lines.append(" a b c d e f g h")
lines.append(" +---+---+---+---+---+---+---+---+") lines.append(" +---+---+---+---+---+---+---+---+")
# Render rank 8 with pieces # Render rank 8 with pieces
pieces_row = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'] pieces_row = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r']
row = " 8 |" row = " 8 |"
@@ -112,10 +112,10 @@ def test_chess_board_render():
row += " 8" row += " 8"
lines.append(row) lines.append(row)
lines.append(" +---+---+---+---+---+---+---+---+") lines.append(" +---+---+---+---+---+---+---+---+")
for line in lines: for line in lines:
output.write(line + "\n") output.write(line + "\n")
rendered = output.getvalue() rendered = output.getvalue()
assert "a b c" in rendered assert "a b c" in rendered
assert "" in rendered # Black rook assert "" in rendered # Black rook
@@ -126,19 +126,19 @@ def test_narrow_terminal_render():
"""Test that menu renders correctly on narrow terminals.""" """Test that menu renders correctly on narrow terminals."""
output = StringIO() output = StringIO()
console = Console(file=output, width=40, height=20, force_terminal=True) console = Console(file=output, width=40, height=20, force_terminal=True)
# Small terminal fallback # Small terminal fallback
console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green"))) console.print(Align.center(Text("♟ SHELLMATE ♟", style="bold green")))
console.print() console.print()
menu_table = Table(show_header=False, box=None) menu_table = Table(show_header=False, box=None)
menu_table.add_column(justify="center") menu_table.add_column(justify="center")
menu_table.add_row(Text("Welcome!", style="cyan")) menu_table.add_row(Text("Welcome!", style="cyan"))
menu_table.add_row("[dim]1. Play[/dim]") menu_table.add_row("[dim]1. Play[/dim]")
menu_table.add_row("[dim]q. Quit[/dim]") menu_table.add_row("[dim]q. Quit[/dim]")
console.print(Align.center(menu_table)) console.print(Align.center(menu_table))
rendered = output.getvalue() rendered = output.getvalue()
assert "SHELLMATE" in rendered assert "SHELLMATE" in rendered
@@ -147,7 +147,7 @@ def test_markup_escape_special_chars():
"""Test that usernames with special chars don't break markup.""" """Test that usernames with special chars don't break markup."""
output = StringIO() output = StringIO()
console = Console(file=output, width=80, height=24, force_terminal=True) console = Console(file=output, width=80, height=24, force_terminal=True)
# Usernames that could break markup # Usernames that could break markup
test_usernames = [ test_usernames = [
"normal_user", "normal_user",
@@ -156,11 +156,11 @@ def test_markup_escape_special_chars():
"[admin]", "[admin]",
"test/path", "test/path",
] ]
for username in test_usernames: for username in test_usernames:
# Using Text() object safely escapes special characters # Using Text() object safely escapes special characters
console.print(Text(f"Welcome, {username}!", style="cyan")) console.print(Text(f"Welcome, {username}!", style="cyan"))
rendered = output.getvalue() rendered = output.getvalue()
assert "normal_user" in rendered assert "normal_user" in rendered
# Text() should have escaped the brackets safely # Text() should have escaped the brackets safely