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
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)