From 1b0b093895a643a1fb049e34ffa2562602ab220f Mon Sep 17 00:00:00 2001 From: Greg Hendrickson Date: Tue, 27 Jan 2026 18:37:56 +0000 Subject: [PATCH] Fix terminal resize crash, improve TUI scaling & styling - Add TerminalSession class with proper resize handler - Fix crash when terminal is resized - Add ASCII art title for wide terminals - Better centered panels with Rich - Green chess board theme with proper piece colors - Stockfish AI integration (falls back to random moves) - Move history display - Responsive layout that adapts to terminal size - Hide/show cursor appropriately --- src/shellmate/ssh/server.py | 406 +++++++++++++++++++++++++++--------- 1 file changed, 311 insertions(+), 95 deletions(-) diff --git a/src/shellmate/ssh/server.py b/src/shellmate/ssh/server.py index 6c48948..ee26cb9 100644 --- a/src/shellmate/ssh/server.py +++ b/src/shellmate/ssh/server.py @@ -44,17 +44,59 @@ class ShellMateSSHServer(asyncssh.SSHServer): return True +class TerminalSession: + """Manages terminal state for an SSH session.""" + + def __init__(self, process: asyncssh.SSHServerProcess): + self.process = process + self.width = 80 + self.height = 24 + self._update_size() + self._resize_event = asyncio.Event() + + def _update_size(self): + """Update terminal size from process.""" + size = self.process.get_terminal_size() + if size: + self.width = max(size[0], 40) + self.height = max(size[1], 10) + + def handle_resize(self, width, height, pixwidth, pixheight): + """Handle terminal resize event.""" + self.width = max(width, 40) + self.height = max(height, 10) + self._resize_event.set() + self._resize_event.clear() + + def write(self, data: str): + """Write string data to terminal.""" + if isinstance(data, str): + data = data.encode('utf-8') + self.process.stdout.write(data) + + def clear(self): + """Clear screen and move cursor home.""" + self.write("\033[2J\033[H") + + def hide_cursor(self): + self.write("\033[?25l") + + def show_cursor(self): + self.write("\033[?25h") + + async def handle_client(process: asyncssh.SSHServerProcess) -> None: """Handle an SSH client session.""" username = process.get_extra_info("username") or "guest" - # Get terminal info - term_type = process.get_terminal_type() or "xterm-256color" - term_size = process.get_terminal_size() - width = term_size[0] if term_size else 80 - height = term_size[1] if term_size else 24 + # Create terminal session + session = TerminalSession(process) - logger.info(f"Client {username}: term={term_type}, size={width}x{height}") + # Set up resize handler + process.channel.set_terminal_size_handler(session.handle_resize) + + term_type = process.get_terminal_type() or "xterm-256color" + logger.info(f"Client {username}: term={term_type}, size={session.width}x{session.height}") # Determine mode if username == "learn": @@ -65,145 +107,268 @@ async def handle_client(process: asyncssh.SSHServerProcess) -> None: mode = "play" try: - # Use simple Rich-based menu (more reliable over SSH) - await run_simple_menu(process, username, mode, width, height) + session.hide_cursor() + await run_simple_menu(process, session, username, mode) except Exception as e: logger.exception(f"Error in ShellMate session: {e}") try: - process.stdout.write(f"\r\n\033[31mError: {e}\033[0m\r\n") + session.write(f"\r\n\033[31mError: {e}\033[0m\r\n") await asyncio.sleep(2) except Exception: pass finally: + session.show_cursor() process.exit(0) -async def run_simple_menu(process, username: str, mode: str, width: int, height: int) -> None: - """Simple Rich-based fallback menu.""" +async def run_simple_menu(process, session: TerminalSession, username: str, mode: str) -> None: + """Beautiful Rich-based menu that scales to terminal size.""" from rich.console import Console from rich.panel import Panel from rich.text import Text from rich.align import Align + from rich.box import DOUBLE, ROUNDED + from rich.table import Table + from rich.layout import Layout import io class ProcessWriter: - def __init__(self, proc): - self._proc = proc + def __init__(self, sess): + self._session = sess def write(self, data): - # Encode string to bytes for binary mode SSH - if isinstance(data, str): - data = data.encode('utf-8') - self._proc.stdout.write(data) + self._session.write(data) def flush(self): pass - writer = ProcessWriter(process) - console = Console(file=writer, width=width, height=height, force_terminal=True) + def render_menu(): + """Render the main menu centered on screen.""" + writer = ProcessWriter(session) + console = Console(file=writer, width=session.width, height=session.height, force_terminal=True) + + session.clear() + + # Calculate vertical padding for centering + menu_height = 18 + top_pad = max(0, (session.height - menu_height) // 2) + + for _ in range(top_pad): + console.print() + + # ASCII art title + if session.width >= 60: + title = """ + ███████╗██╗ ██╗███████╗██╗ ██╗ ███╗ ███╗ █████╗ ████████╗███████╗ + ██╔════╝██║ ██║██╔════╝██║ ██║ ████╗ ████║██╔══██╗╚══██╔══╝██╔════╝ + ███████╗███████║█████╗ ██║ ██║ ██╔████╔██║███████║ ██║ █████╗ + ╚════██║██╔══██║██╔══╝ ██║ ██║ ██║╚██╔╝██║██╔══██║ ██║ ██╔══╝ + ███████║██║ ██║███████╗███████╗███████╗██║ ╚═╝ ██║██║ ██║ ██║ ███████╗ + ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ + """ + console.print(Text(title, style="bold green")) + else: + console.print(Align.center(Text("♟️ SHELLMATE ♟️", style="bold green"))) + + console.print(Align.center(Text("SSH into Chess Mastery", style="dim italic"))) + console.print() + + # Menu panel + menu_content = f""" +[cyan]Welcome, [bold]{username}[/bold]![/cyan] + + [bold white][[1]][/] ⚔️ Play vs AI + [bold white][[2]][/] 👥 Play vs Human + [bold white][[3]][/] 📚 Learn & Practice + [bold white][[q]][/] 🚪 Quit + +[dim]Press a key to select...[/dim] +""" + panel_width = min(50, session.width - 4) + panel = Panel( + Align.center(menu_content), + box=ROUNDED, + border_style="bright_blue", + width=panel_width, + title="[bold white]♔ Main Menu ♔[/]", + subtitle=f"[dim]{session.width}x{session.height}[/dim]" + ) + console.print(Align.center(panel)) - # Clear screen - console.print("\033[2J\033[H", end="") + render_menu() - # Welcome banner - banner = """ -╔═══════════════════════════════════════╗ -║ ♟️ S H E L L M A T E ♟️ ║ -║ SSH into Chess Mastery ║ -╚═══════════════════════════════════════╝ - """ - console.print(Align.center(Text(banner, style="bold green"))) - console.print() - console.print(Align.center(f"[cyan]Welcome, {username}![/cyan]")) - console.print() - console.print(Align.center("[bold]Select mode:[/bold]")) - console.print() - console.print(Align.center(" [1] ⚔️ Play vs AI")) - console.print(Align.center(" [2] 👥 Play vs Human")) - console.print(Align.center(" [3] 📚 Learn")) - console.print(Align.center(" [q] Quit")) - console.print() - console.print(Align.center("[dim]Press a key...[/dim]")) - - # Wait for input + # Wait for input with resize handling while True: try: - data = await process.stdin.read(1) + # Use asyncio.wait with timeout to check for resize + read_task = asyncio.create_task(process.stdin.read(1)) + + done, pending = await asyncio.wait( + [read_task], + timeout=0.5, + return_when=asyncio.FIRST_COMPLETED + ) + + # Check if resize happened + if session._resize_event.is_set(): + render_menu() + continue + + if not done: + continue + + data = read_task.result() if not data: break + char = data.decode() if isinstance(data, bytes) else data if char in ('q', 'Q', '\x03', '\x04'): # q, Ctrl+C, Ctrl+D - console.print("\r\n[yellow]Goodbye![/yellow]\r\n") + session.clear() + session.write("\r\n\033[33mGoodbye! Thanks for playing!\033[0m\r\n\r\n") break elif char == '1': - console.print("\r\n[green]Starting game vs AI...[/green]") - await run_chess_game(process, console, username, "ai") + await run_chess_game(process, session, username, "ai") break elif char == '2': - console.print("\r\n[yellow]Matchmaking coming soon![/yellow]") + session.clear() + session.write("\r\n\033[33mMatchmaking coming soon! Try playing vs AI.\033[0m\r\n") + await asyncio.sleep(2) + render_menu() elif char == '3': - console.print("\r\n[yellow]Tutorials coming soon![/yellow]") + session.clear() + session.write("\r\n\033[33mTutorials coming soon! Try playing vs AI.\033[0m\r\n") + await asyncio.sleep(2) + render_menu() + except asyncio.CancelledError: + break except Exception as e: logger.error(f"Input error: {e}") - break + continue # Don't break on errors, try to continue -async def run_chess_game(process, console, username: str, opponent: str) -> None: - """Run a chess game session.""" +async def run_chess_game(process, session: TerminalSession, username: str, opponent: str) -> None: + """Run a beautiful chess game session with Stockfish AI.""" import chess - from rich.table import Table + from rich.console import Console + from rich.panel import Panel from rich.align import Align + from rich.text import Text + from rich.box import ROUNDED + + class ProcessWriter: + def __init__(self, sess): + self._session = sess + def write(self, data): + self._session.write(data) + def flush(self): + pass board = chess.Board() + move_history = [] + status_msg = "" - # Unicode pieces + # Try to use Stockfish + stockfish_engine = None + try: + from stockfish import Stockfish + stockfish_engine = Stockfish(path="/usr/games/stockfish", depth=10) + stockfish_engine.set_skill_level(5) # Medium difficulty + except Exception as e: + logger.warning(f"Stockfish not available: {e}") + + # Unicode pieces with better styling PIECES = { 'K': '♔', 'Q': '♕', 'R': '♖', 'B': '♗', 'N': '♘', 'P': '♙', 'k': '♚', 'q': '♛', 'r': '♜', 'b': '♝', 'n': '♞', 'p': '♟', } def render_board(): - console.print("\033[2J\033[H", end="") + nonlocal status_msg + writer = ProcessWriter(session) + console = Console(file=writer, width=session.width, height=session.height, force_terminal=True) - # File labels - console.print(" a b c d e f g h") - console.print(" ╔═══╤═══╤═══╤═══╤═══╤═══╤═══╤═══╗") + session.clear() + + # Calculate centering + board_width = 42 + left_pad = max(0, (session.width - board_width) // 2) + pad = " " * left_pad + + # Title + console.print() + console.print(Align.center(Text("♔ SHELLMATE CHESS ♔", style="bold cyan"))) + console.print() + + # Board with better colors + console.print(f"{pad} a b c d e f g h") + console.print(f"{pad} ┌───┬───┬───┬───┬───┬───┬───┬───┐") for rank in range(7, -1, -1): - row = f" {rank + 1} ║" + row = f"{pad}{rank + 1} │" for file in range(8): square = chess.square(file, rank) piece = board.piece_at(square) is_light = (rank + file) % 2 == 1 - bg = "on #6d8b5e" if is_light else "on #3d5a45" + # Green theme for board + if is_light: + bg = "on #769656" # Light green + else: + bg = "on #4a7c3f" # Dark green if piece: char = PIECES.get(piece.symbol(), '?') - fg = "white" if piece.color == chess.WHITE else "black" - row += f"[{fg} {bg}] {char} [/]" + # White pieces in white/cream, black pieces in dark + if piece.color == chess.WHITE: + fg = "#ffffff bold" + else: + fg = "#1a1a1a bold" + row += f"[{fg} {bg}] {char} [/]│" else: - row += f"[{bg}] [/]" - - if file < 7: - row += "│" + row += f"[{bg}] [/]│" - row += f"║ {rank + 1}" + row += f" {rank + 1}" console.print(row) if rank > 0: - console.print(" ╟───┼───┼───┼───┼───┼───┼───┼───╢") + console.print(f"{pad} ├───┼───┼───┼───┼───┼───┼───┼───┤") - console.print(" ╚═══╧═══╧═══╧═══╧═══╧═══╧═══╧═══╝") - console.print(" a b c d e f g h") + console.print(f"{pad} └───┴───┴───┴───┴───┴───┴───┴───┘") + console.print(f"{pad} a b c d e f g h") console.print() - turn = "White" if board.turn == chess.WHITE else "Black" - console.print(f"[bold]{turn} to move[/bold]") + # Status panel + turn = "White ♔" if board.turn == chess.WHITE else "Black ♚" + turn_style = "bold white" if board.turn == chess.WHITE else "bold" + + status_lines = [] + status_lines.append(f"[{turn_style}]{turn} to move[/]") if board.is_check(): - console.print("[red bold]CHECK![/red bold]") + status_lines.append("[red bold]⚠️ CHECK![/red bold]") - console.print("\r\n[dim]Enter move (e.g., e2e4) or 'q' to quit:[/dim] ", end="") + if move_history: + last_moves = move_history[-3:] + status_lines.append(f"[dim]Moves: {' '.join(last_moves)}[/dim]") + + if status_msg: + status_lines.append(f"[yellow]{status_msg}[/yellow]") + status_msg = "" + + status_lines.append("") + status_lines.append("[dim]Enter move (e.g. e2e4) │ [q]uit │ [r]esign[/dim]") + + status_panel = Panel( + "\n".join(status_lines), + box=ROUNDED, + border_style="blue", + width=min(50, session.width - 4), + title="[bold]Game Status[/bold]" + ) + console.print(Align.center(status_panel)) + + # Show cursor for input + session.show_cursor() + console.print(Align.center("[cyan]Move: [/cyan]"), end="") render_board() @@ -211,7 +376,24 @@ async def run_chess_game(process, console, username: str, opponent: str) -> None while not board.is_game_over(): try: - data = await process.stdin.read(1) + read_task = asyncio.create_task(process.stdin.read(1)) + + done, pending = await asyncio.wait( + [read_task], + timeout=0.5, + return_when=asyncio.FIRST_COMPLETED + ) + + # Handle resize + if session._resize_event.is_set(): + render_board() + session.write(move_buffer) + continue + + if not done: + continue + + data = read_task.result() if not data: break @@ -219,58 +401,92 @@ async def run_chess_game(process, console, username: str, opponent: str) -> None if char in ('\x03', '\x04'): # Ctrl+C/D break - elif char == 'q': - console.print("\r\n[yellow]Game ended.[/yellow]") + elif char in ('q', 'r'): + status_msg = "Game resigned. Thanks for playing!" + render_board() + await asyncio.sleep(2) break elif char == '\r' or char == '\n': - # Process move if move_buffer: try: - move = chess.Move.from_uci(move_buffer.strip()) + 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(): - console.print("[cyan]AI thinking...[/cyan]") - await asyncio.sleep(0.5) + 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)) - # Simple random legal move for now - import random - ai_move = random.choice(list(board.legal_moves)) board.push(ai_move) + move_history.append(ai_move.uci()) render_board() else: - console.print(f"\r\n[red]Illegal move: {move_buffer}[/red]") - console.print("[dim]Enter move:[/dim] ", end="") + status_msg = f"Illegal move: {move_buffer}" move_buffer = "" + render_board() except Exception as e: - console.print(f"\r\n[red]Invalid: {move_buffer}[/red]") - console.print("[dim]Enter move:[/dim] ", end="") + status_msg = f"Invalid move format: {move_buffer}" move_buffer = "" + render_board() elif char == '\x7f' or char == '\b': # Backspace if move_buffer: move_buffer = move_buffer[:-1] - process.stdout.write(b'\b \b') - elif char.isprintable(): + session.write('\b \b') + elif char.isprintable() and len(move_buffer) < 5: move_buffer += char - process.stdout.write(char.encode('utf-8')) + session.write(char) + except asyncio.CancelledError: + break except Exception as e: logger.error(f"Game input error: {e}") - break + continue if board.is_game_over(): + session.clear() + writer = ProcessWriter(session) + console = Console(file=writer, width=session.width, height=session.height, force_terminal=True) + console.print() if board.is_checkmate(): - winner = "Black" if board.turn == chess.WHITE else "White" - console.print(f"[bold green]Checkmate! {winner} wins![/bold green]") + winner = "Black ♚" if board.turn == chess.WHITE else "White ♔" + console.print(Align.center(Panel( + f"[bold green]🏆 CHECKMATE! 🏆\n\n{winner} wins![/bold green]", + box=ROUNDED, + border_style="green", + width=40 + ))) elif board.is_stalemate(): - console.print("[yellow]Stalemate! Draw.[/yellow]") + console.print(Align.center(Panel( + "[yellow]Stalemate!\n\nThe game is a draw.[/yellow]", + box=ROUNDED, + border_style="yellow", + width=40 + ))) else: - console.print("[yellow]Game over - Draw.[/yellow]") + console.print(Align.center(Panel( + "[yellow]Game Over\n\nDraw by repetition or insufficient material.[/yellow]", + box=ROUNDED, + border_style="yellow", + width=40 + ))) await asyncio.sleep(3)