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
This commit is contained in:
Greg Hendrickson
2026-01-27 18:37:56 +00:00
parent 692c6c92dc
commit 1b0b093895

View File

@@ -44,17 +44,59 @@ class ShellMateSSHServer(asyncssh.SSHServer):
return True 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: 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"
# Get terminal info # Create terminal session
term_type = process.get_terminal_type() or "xterm-256color" session = TerminalSession(process)
term_size = process.get_terminal_size()
width = term_size[0] if term_size else 80
height = term_size[1] if term_size else 24
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 # Determine mode
if username == "learn": if username == "learn":
@@ -65,145 +107,268 @@ async def handle_client(process: asyncssh.SSHServerProcess) -> None:
mode = "play" mode = "play"
try: try:
# Use simple Rich-based menu (more reliable over SSH) session.hide_cursor()
await run_simple_menu(process, username, mode, width, height) await run_simple_menu(process, session, username, mode)
except Exception as e: except Exception as e:
logger.exception(f"Error in ShellMate session: {e}") logger.exception(f"Error in ShellMate session: {e}")
try: 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) await asyncio.sleep(2)
except Exception: except Exception:
pass pass
finally: finally:
session.show_cursor()
process.exit(0) process.exit(0)
async def run_simple_menu(process, username: str, mode: str, width: int, height: int) -> None: async def run_simple_menu(process, session: TerminalSession, username: str, mode: str) -> None:
"""Simple Rich-based fallback menu.""" """Beautiful Rich-based menu that scales to terminal size."""
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text from rich.text import Text
from rich.align import Align from rich.align import Align
from rich.box import DOUBLE, ROUNDED
from rich.table import Table
from rich.layout import Layout
import io import io
class ProcessWriter: class ProcessWriter:
def __init__(self, proc): def __init__(self, sess):
self._proc = proc self._session = sess
def write(self, data): def write(self, data):
# Encode string to bytes for binary mode SSH self._session.write(data)
if isinstance(data, str):
data = data.encode('utf-8')
self._proc.stdout.write(data)
def flush(self): def flush(self):
pass pass
writer = ProcessWriter(process) def render_menu():
console = Console(file=writer, width=width, height=height, force_terminal=True) """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 render_menu()
console.print("\033[2J\033[H", end="")
# Welcome banner # Wait for input with resize handling
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
while True: while True:
try: 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: if not data:
break break
char = data.decode() if isinstance(data, bytes) else data char = data.decode() if isinstance(data, bytes) else data
if char in ('q', 'Q', '\x03', '\x04'): # q, Ctrl+C, Ctrl+D 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 break
elif char == '1': elif char == '1':
console.print("\r\n[green]Starting game vs AI...[/green]") await run_chess_game(process, session, username, "ai")
await run_chess_game(process, console, username, "ai")
break break
elif char == '2': 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': 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: except Exception as e:
logger.error(f"Input error: {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: async def run_chess_game(process, session: TerminalSession, username: str, opponent: str) -> None:
"""Run a chess game session.""" """Run a beautiful chess game session with Stockfish AI."""
import chess import chess
from rich.table import Table from rich.console import Console
from rich.panel import Panel
from rich.align import Align 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() 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 = { 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': '',
} }
def render_board(): 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 session.clear()
console.print(" a b c d e f g h")
console.print(" ╔═══╤═══╤═══╤═══╤═══╤═══╤═══╤═══╗") # 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): for rank in range(7, -1, -1):
row = f" {rank + 1} " row = f"{pad}{rank + 1} "
for file in range(8): for file in range(8):
square = chess.square(file, rank) square = chess.square(file, rank)
piece = board.piece_at(square) piece = board.piece_at(square)
is_light = (rank + file) % 2 == 1 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: if piece:
char = PIECES.get(piece.symbol(), '?') char = PIECES.get(piece.symbol(), '?')
fg = "white" if piece.color == chess.WHITE else "black" # White pieces in white/cream, black pieces in dark
row += f"[{fg} {bg}] {char} [/]" if piece.color == chess.WHITE:
fg = "#ffffff bold"
else:
fg = "#1a1a1a bold"
row += f"[{fg} {bg}] {char} [/]│"
else: else:
row += f"[{bg}] [/]" row += f"[{bg}] [/]"
if file < 7:
row += ""
row += f" {rank + 1}" row += f" {rank + 1}"
console.print(row) console.print(row)
if rank > 0: if rank > 0:
console.print(" ───┼───┼───┼───┼───┼───┼───┼───") console.print(f"{pad} ───┼───┼───┼───┼───┼───┼───┼───")
console.print(" ╚═══╧═══╧═══╧═══╧═══╧═══╧═══╧═══╝") console.print(f"{pad} └───┴───┴───┴───┴───┴───┴───┴───┘")
console.print(" a b c d e f g h") console.print(f"{pad} a b c d e f g h")
console.print() console.print()
turn = "White" if board.turn == chess.WHITE else "Black" # Status panel
console.print(f"[bold]{turn} to move[/bold]") 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(): 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() render_board()
@@ -211,7 +376,24 @@ async def run_chess_game(process, console, username: str, opponent: str) -> None
while not board.is_game_over(): while not board.is_game_over():
try: 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: if not data:
break 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 if char in ('\x03', '\x04'): # Ctrl+C/D
break break
elif char == 'q': elif char in ('q', 'r'):
console.print("\r\n[yellow]Game ended.[/yellow]") status_msg = "Game resigned. Thanks for playing!"
render_board()
await asyncio.sleep(2)
break break
elif char == '\r' or char == '\n': elif char == '\r' or char == '\n':
# Process move
if move_buffer: if move_buffer:
try: try:
move = chess.Move.from_uci(move_buffer.strip()) move = chess.Move.from_uci(move_buffer.strip().lower())
if move in board.legal_moves: if move in board.legal_moves:
board.push(move) board.push(move)
move_history.append(move_buffer.lower())
move_buffer = "" move_buffer = ""
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():
console.print("[cyan]AI thinking...[/cyan]") session.hide_cursor()
await asyncio.sleep(0.5) 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) board.push(ai_move)
move_history.append(ai_move.uci())
render_board() render_board()
else: else:
console.print(f"\r\n[red]Illegal move: {move_buffer}[/red]") status_msg = f"Illegal move: {move_buffer}"
console.print("[dim]Enter move:[/dim] ", end="")
move_buffer = "" move_buffer = ""
render_board()
except Exception as e: except Exception as e:
console.print(f"\r\n[red]Invalid: {move_buffer}[/red]") status_msg = f"Invalid move format: {move_buffer}"
console.print("[dim]Enter move:[/dim] ", end="")
move_buffer = "" move_buffer = ""
render_board()
elif char == '\x7f' or char == '\b': # Backspace elif char == '\x7f' or char == '\b': # Backspace
if move_buffer: if move_buffer:
move_buffer = move_buffer[:-1] move_buffer = move_buffer[:-1]
process.stdout.write(b'\b \b') session.write('\b \b')
elif char.isprintable(): elif char.isprintable() and len(move_buffer) < 5:
move_buffer += char move_buffer += char
process.stdout.write(char.encode('utf-8')) session.write(char)
except asyncio.CancelledError:
break
except Exception as e: except Exception as e:
logger.error(f"Game input error: {e}") logger.error(f"Game input error: {e}")
break continue
if board.is_game_over(): 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() 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"
console.print(f"[bold green]Checkmate! {winner} wins![/bold green]") 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(): 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: 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) await asyncio.sleep(3)