diff --git a/src/shellmate/ssh/server.py b/src/shellmate/ssh/server.py index 9d2db51..c4aacc1 100644 --- a/src/shellmate/ssh/server.py +++ b/src/shellmate/ssh/server.py @@ -281,132 +281,78 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon 'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟', } + # Selection state for two-step moves + selected_square = None # None or chess square int + legal_targets = set() # Set of legal destination squares + + def get_cell_style(square, piece, is_light): + """Get ANSI style for a cell based on selection state.""" + is_selected = (square == selected_square) + is_target = (square in legal_targets) + + # Background colors + if is_selected: + bg = "\033[48;5;33m" # Blue for selected + elif is_target: + bg = "\033[48;5;28m" # Green for legal moves + elif is_light: + bg = "" # Default terminal + else: + bg = "\033[48;5;236m" # Dark grey + + bg_end = "\033[0m" if bg else "" + return bg, bg_end + def render_board(): - """Large, terminal-filling chess board.""" + """Large, terminal-filling chess board with selection highlighting.""" nonlocal status_msg session._update_size() session.clear() - # Determine cell size based on terminal - # Big board for terminals 80+ wide, 40+ tall - big = session.width >= 80 and session.height >= 40 - + # Use compact board to fit most terminals lines = [] # Title lines.append("") - if big: - lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m") - lines.append("\033[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\033[0m") - else: - lines.append("\033[1;32m♔ SHELLMATE ♔\033[0m") + lines.append("\033[1;32m♔ S H E L L M A T E ♔\033[0m") lines.append("") - if big: - # BIG BOARD - 7 char wide cells, 3 rows tall - cell_w = 7 - lines.append(" A B C D E F G H") - lines.append(" ╔═══════╤═══════╤═══════╤═══════╤═══════╤═══════╤═══════╤═══════╗") - - for rank in range(7, -1, -1): - # Top padding row - pad_row = " ║" - for file in range(8): - is_light = (rank + file) % 2 == 1 - fill = " " if is_light else "\033[48;5;236m \033[0m" - pad_row += fill - if file < 7: - pad_row += "│" - pad_row += "║" - lines.append(pad_row) + # Column labels + lines.append(" A B C D E F G H") + lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗") + + for rank in range(7, -1, -1): + # Piece row + piece_row = f" \033[1;36m{rank + 1}\033[0m ║" + for file in range(8): + square = chess.square(file, rank) + piece = board.piece_at(square) + is_light = (rank + file) % 2 == 1 + bg, bg_end = get_cell_style(square, piece, is_light) - # Piece row - piece_row = f" \033[1;36m{rank + 1}\033[0m ║" - for file in range(8): - square = chess.square(file, rank) - piece = board.piece_at(square) - is_light = (rank + file) % 2 == 1 - bg = "" if is_light else "\033[48;5;236m" - bg_end = "" if is_light else "\033[0m" - - if piece: - char = PIECES.get(piece.symbol(), '?') - if piece.color == chess.WHITE: - piece_row += f"{bg} \033[1;97m{char}\033[0m{bg} {bg_end}" - else: - piece_row += f"{bg} \033[1;33m{char}\033[0m{bg} {bg_end}" + if piece: + char = PIECES.get(piece.symbol(), '?') + if piece.color == chess.WHITE: + piece_row += f"{bg} \033[1;97m{char}\033[0m{bg} {bg_end}" else: - piece_row += f"{bg} {bg_end}" - - if file < 7: - piece_row += "│" - piece_row += f"║ \033[1;36m{rank + 1}\033[0m" - lines.append(piece_row) + piece_row += f"{bg} \033[1;33m{char}\033[0m{bg} {bg_end}" + elif square in legal_targets: + # Show dot for legal empty target + piece_row += f"{bg} \033[1;32m·\033[0m{bg} {bg_end}" + else: + piece_row += f"{bg} {bg_end}" - # Bottom padding row - pad_row = " ║" - for file in range(8): - is_light = (rank + file) % 2 == 1 - fill = " " if is_light else "\033[48;5;236m \033[0m" - pad_row += fill - if file < 7: - pad_row += "│" - pad_row += "║" - lines.append(pad_row) - - # Row separator - if rank > 0: - lines.append(" ╟───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────╢") + if file < 7: + piece_row += "│" + piece_row += f"║ \033[1;36m{rank + 1}\033[0m" + lines.append(piece_row) - lines.append(" ╚═══════╧═══════╧═══════╧═══════╧═══════╧═══════╧═══════╧═══════╝") - lines.append(" A B C D E F G H") - board_width = 73 - else: - # COMPACT BOARD - 5 char cells, 2 rows tall - lines.append(" A B C D E F G H") - lines.append(" ╔═════╤═════╤═════╤═════╤═════╤═════╤═════╤═════╗") - - for rank in range(7, -1, -1): - # Piece row - piece_row = f" \033[1;36m{rank + 1}\033[0m ║" - for file in range(8): - square = chess.square(file, rank) - piece = board.piece_at(square) - is_light = (rank + file) % 2 == 1 - bg = "" if is_light else "\033[48;5;236m" - bg_end = "" if is_light else "\033[0m" - - if piece: - char = PIECES.get(piece.symbol(), '?') - if piece.color == chess.WHITE: - piece_row += f"{bg} \033[1;97m{char}\033[0m{bg} {bg_end}" - else: - piece_row += f"{bg} \033[1;33m{char}\033[0m{bg} {bg_end}" - else: - piece_row += f"{bg} {bg_end}" - - if file < 7: - piece_row += "│" - piece_row += f"║ \033[1;36m{rank + 1}\033[0m" - lines.append(piece_row) - - # Padding row - pad_row = " ║" - for file in range(8): - is_light = (rank + file) % 2 == 1 - fill = " " if is_light else "\033[48;5;236m \033[0m" - pad_row += fill - if file < 7: - pad_row += "│" - pad_row += "║" - lines.append(pad_row) - - if rank > 0: - lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢") - - lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝") - lines.append(" A B C D E F G H") - board_width = 57 + if rank > 0: + lines.append(" ╟─────┼─────┼─────┼─────┼─────┼─────┼─────┼─────╢") + + lines.append(" ╚═════╧═════╧═════╧═════╧═════╧═════╧═════╧═════╝") + lines.append(" A B C D E F G H") + board_width = 57 lines.append("") @@ -428,33 +374,69 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon status_msg = "" lines.append("") - lines.append(" \033[32mType move\033[0m (e.g. e2e4) │ \033[31mq\033[0m = quit │ \033[31mr\033[0m = resign") + + # Instructions based on state + if selected_square is not None: + sq_name = chess.square_name(selected_square).upper() + lines.append(f" \033[36mSelected: {sq_name}\033[0m → Type destination (or ESC to cancel)") + else: + lines.append(" Type \033[36msquare\033[0m (e.g. E2) to select │ \033[31mQ\033[0m = quit") + lines.append("") # Calculate centering total_height = len(lines) - - # Horizontal padding left_pad = max(0, (session.width - board_width) // 2) pad = " " * left_pad - - # Vertical centering top_pad = max(0, (session.height - total_height) // 2) - # Output for _ in range(top_pad): session.write("\r\n") for line in lines: session.write(pad + line + "\r\n") - # Move prompt - session.write(pad + " \033[36m> \033[0m") + # Input prompt + if selected_square is not None: + session.write(pad + f" \033[32m→ \033[0m") + else: + session.write(pad + f" \033[36m> \033[0m") session.show_cursor() + def parse_square(text): + """Parse a square name like 'e2' or 'E2' into a chess square.""" + text = text.strip().lower() + if len(text) != 2: + return None + file_char, rank_char = text[0], text[1] + if file_char not in 'abcdefgh' or rank_char not in '12345678': + return None + file_idx = ord(file_char) - ord('a') + rank_idx = int(rank_char) - 1 + return chess.square(file_idx, rank_idx) + + def select_square(sq): + """Select a square and compute legal moves from it.""" + nonlocal selected_square, legal_targets + selected_square = sq + legal_targets = set() + + piece = board.piece_at(sq) + if piece and piece.color == board.turn: + # Find all legal moves from this square + for move in board.legal_moves: + if move.from_square == sq: + legal_targets.add(move.to_square) + + def clear_selection(): + """Clear the current selection.""" + nonlocal selected_square, legal_targets + selected_square = None + legal_targets = set() + render_board() - move_buffer = "" + input_buffer = "" while not board.is_game_over(): try: @@ -463,63 +445,111 @@ async def run_chess_game(process, session: TerminalSession, username: str, oppon break char = data.decode() if isinstance(data, bytes) else data - - # Update terminal size in case it changed session._update_size() - if char in ('\x03', '\x04'): # Ctrl+C/D - break - elif char in ('q', 'r'): - status_msg = "Game resigned. Thanks for playing!" + # Quit + if char.lower() == 'q' and not input_buffer: + status_msg = "Thanks for playing!" + clear_selection() render_board() - await asyncio.sleep(2) + await asyncio.sleep(1) break - elif char == '\r' or char == '\n': - if move_buffer: - try: - move = chess.Move.from_uci(move_buffer.strip().lower()) - if move in board.legal_moves: - board.push(move) - move_history.append(move_buffer.lower()) - move_buffer = "" - render_board() - - # AI response - if opponent == "ai" and not board.is_game_over(): - session.hide_cursor() - session.write("\r\n\033[36m AI thinking...\033[0m") - - if stockfish_engine: - try: - stockfish_engine.set_fen_position(board.fen()) - best_move = stockfish_engine.get_best_move() - ai_move = chess.Move.from_uci(best_move) - except: - import random - ai_move = random.choice(list(board.legal_moves)) - else: - import random - await asyncio.sleep(0.5) - ai_move = random.choice(list(board.legal_moves)) - - board.push(ai_move) - move_history.append(ai_move.uci()) + + # Escape - cancel selection + if char == '\x1b': + if selected_square is not None: + clear_selection() + render_board() + input_buffer = "" + continue + + # Ctrl+C/D + if char in ('\x03', '\x04'): + break + + # Backspace + if char == '\x7f' or char == '\b': + if input_buffer: + input_buffer = input_buffer[:-1] + session.write('\b \b') + continue + + # Enter - process input + if char in ('\r', '\n'): + if input_buffer: + sq = parse_square(input_buffer) + input_buffer = "" + + if sq is not None: + if selected_square is None: + # First click - select piece + piece = board.piece_at(sq) + if piece and piece.color == board.turn: + select_square(sq) + render_board() + else: + status_msg = "Select your own piece" render_board() else: - status_msg = f"Illegal move: {move_buffer}" - move_buffer = "" - render_board() - except Exception as e: - status_msg = f"Invalid move format: {move_buffer}" - move_buffer = "" + # Second click - make move + if sq in legal_targets: + move = chess.Move(selected_square, sq) + # Check for promotion + piece = board.piece_at(selected_square) + if piece and piece.piece_type == chess.PAWN: + if (piece.color == chess.WHITE and chess.square_rank(sq) == 7) or \ + (piece.color == chess.BLACK and chess.square_rank(sq) == 0): + move = chess.Move(selected_square, sq, promotion=chess.QUEEN) + + board.push(move) + move_history.append(move.uci()) + clear_selection() + render_board() + + # AI response + if opponent == "ai" and not board.is_game_over(): + session.hide_cursor() + session.write("\r\n \033[36mAI thinking...\033[0m") + + if stockfish_engine: + try: + stockfish_engine.set_fen_position(board.fen()) + best_move = stockfish_engine.get_best_move() + ai_move = chess.Move.from_uci(best_move) + except: + import random + ai_move = random.choice(list(board.legal_moves)) + else: + import random + await asyncio.sleep(0.5) + ai_move = random.choice(list(board.legal_moves)) + + board.push(ai_move) + move_history.append(ai_move.uci()) + render_board() + elif sq == selected_square: + # Clicked same square - deselect + clear_selection() + render_board() + else: + # Try selecting new piece + piece = board.piece_at(sq) + if piece and piece.color == board.turn: + select_square(sq) + render_board() + else: + status_msg = "Invalid move" + clear_selection() + render_board() + else: + status_msg = "Invalid square (use a1-h8)" render_board() - elif char == '\x7f' or char == '\b': # Backspace - if move_buffer: - move_buffer = move_buffer[:-1] - session.write('\b \b') - elif char.isprintable() and len(move_buffer) < 5: - move_buffer += char - session.write(char) + continue + + # Regular character input + if char.isprintable() and len(input_buffer) < 2: + input_buffer += char + session.write(char.upper()) except asyncio.CancelledError: break