mirror of
https://github.com/ghndrx/shellmate.git
synced 2026-02-10 06:45:02 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user