""" Tab completion support for terminal client REPL. Provides intelligent completion for commands, options, and values using Python's cmd module completion hooks. Author: Claude Date: 2025-10-27 """ import logging from typing import List, Optional logger = logging.getLogger(f'{__name__}.completions') class CompletionHelper: """Helper class for generating tab completions.""" # Valid values for common options VALID_LEAGUES = ['sba', 'pd'] VALID_ALIGNMENTS = ['normal', 'shifted_left', 'shifted_right', 'extreme_shift'] VALID_INFIELD_DEPTHS = ['in', 'normal', 'back', 'double_play'] VALID_OUTFIELD_DEPTHS = ['in', 'normal', 'back'] VALID_APPROACHES = ['normal', 'contact', 'power', 'patient'] # Valid bases for stealing/holding VALID_BASES = ['1', '2', '3'] # Valid PlayOutcome values for resolve_with command VALID_OUTCOMES = [ 'strikeout', 'groundball_a', 'groundball_b', 'groundball_c', 'flyout_a', 'flyout_b', 'flyout_c', 'lineout', 'popout', 'single_1', 'single_2', 'single_uncapped', 'double_2', 'double_3', 'double_uncapped', 'triple', 'homerun', 'walk', 'hbp', 'intentional_walk', 'error', 'wild_pitch', 'passed_ball', 'stolen_base', 'caught_stealing', 'balk', 'pick_off', 'bp_homerun', 'bp_single', 'bp_flyout', 'bp_lineout' ] # Valid positions for fielding rolls VALID_POSITIONS = ['P', 'C', '1B', '2B', '3B', 'SS', 'LF', 'CF', 'RF'] @staticmethod def filter_completions(text: str, options: List[str]) -> List[str]: """ Filter options that start with the given text. Args: text: Partial text to match options: List of possible completions Returns: List of matching options """ if not text: return options return [opt for opt in options if opt.startswith(text)] @staticmethod def complete_option(text: str, line: str, available_options: List[str]) -> List[str]: """ Complete option names (--option). Args: text: Current text being completed line: Full command line available_options: List of valid option names Returns: List of matching options with -- prefix """ if text.startswith('--'): # Completing option name prefix = text[2:] matches = [opt for opt in available_options if opt.startswith(prefix)] return [f'--{match}' for match in matches] elif not text: # Show all options return [f'--{opt}' for opt in available_options] return [] @staticmethod def get_current_option(line: str, endidx: int) -> Optional[str]: """ Determine which option we're currently completing the value for. Args: line: Full command line endidx: Current cursor position Returns: Option name (without --) or None """ # Split line up to cursor position before_cursor = line[:endidx] tokens = before_cursor.split() # Look for the last --option before cursor for i in range(len(tokens) - 1, -1, -1): if tokens[i].startswith('--'): return tokens[i][2:].replace('-', '_') return None class GameREPLCompletions: """Mixin class providing tab completion methods for GameREPL.""" def __init__(self): """Initialize completion helper.""" self.completion_helper = CompletionHelper() # ==================== Command Completions ==================== def complete_new_game(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete new_game command. Available options: --league sba|pd --home-team N --away-team N """ available_options = ['league', 'home-team', 'away-team'] # Check if we're completing an option value first current_option = self.completion_helper.get_current_option(line, endidx) if current_option == 'league': return self.completion_helper.filter_completions( text, self.completion_helper.VALID_LEAGUES ) # Check if we're completing an option name if text.startswith('--'): return self.completion_helper.complete_option(text, line, available_options) elif not text: return self.completion_helper.complete_option(text, line, available_options) return [] def complete_defensive(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete defensive command. Available options: --alignment normal|shifted_left|shifted_right|extreme_shift --infield in|normal|back|double_play --outfield in|normal|back --hold 1,2,3 """ available_options = ['alignment', 'infield', 'outfield', 'hold'] # Check if we're completing an option value first current_option = self.completion_helper.get_current_option(line, endidx) if current_option == 'alignment': return self.completion_helper.filter_completions( text, self.completion_helper.VALID_ALIGNMENTS ) elif current_option == 'infield': return self.completion_helper.filter_completions( text, self.completion_helper.VALID_INFIELD_DEPTHS ) elif current_option == 'outfield': return self.completion_helper.filter_completions( text, self.completion_helper.VALID_OUTFIELD_DEPTHS ) elif current_option == 'hold': # For comma-separated values, complete the last item if ',' in text: prefix = text.rsplit(',', 1)[0] + ',' last_item = text.rsplit(',', 1)[1] matches = self.completion_helper.filter_completions( last_item, self.completion_helper.VALID_BASES ) return [f'{prefix}{match}' for match in matches] else: return self.completion_helper.filter_completions( text, self.completion_helper.VALID_BASES ) # Check if we're completing an option name if text.startswith('--'): return self.completion_helper.complete_option(text, line, available_options) elif not text: return self.completion_helper.complete_option(text, line, available_options) return [] def complete_offensive(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete offensive command. Available options: --approach normal|contact|power|patient --steal 2,3 --hit-run (flag) --bunt (flag) """ available_options = ['approach', 'steal', 'hit-run', 'bunt'] # Check if we're completing an option value first current_option = self.completion_helper.get_current_option(line, endidx) if current_option == 'approach': return self.completion_helper.filter_completions( text, self.completion_helper.VALID_APPROACHES ) elif current_option == 'steal': # Only bases 2 and 3 can be stolen valid_steal_bases = ['2', '3'] if ',' in text: prefix = text.rsplit(',', 1)[0] + ',' last_item = text.rsplit(',', 1)[1] matches = self.completion_helper.filter_completions( last_item, valid_steal_bases ) return [f'{prefix}{match}' for match in matches] else: return self.completion_helper.filter_completions( text, valid_steal_bases ) # Check if we're completing an option name if text.startswith('--'): return self.completion_helper.complete_option(text, line, available_options) elif not text: return self.completion_helper.complete_option(text, line, available_options) return [] def complete_use_game(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete use_game command with available game IDs. """ # Import here to avoid circular dependency from app.core.state_manager import state_manager # Get list of active games game_ids = state_manager.list_games() game_id_strs = [str(gid) for gid in game_ids] return self.completion_helper.filter_completions(text, game_id_strs) def complete_quick_play(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete quick_play command with common counts. """ # Suggest common play counts common_counts = ['1', '5', '10', '27', '50', '100'] # Check if completing a positional number if text and text.isdigit(): return self.completion_helper.filter_completions(text, common_counts) elif not text and line.strip() == 'quick_play': return common_counts return [] # ==================== New Command Completions ==================== def complete_roll_jump(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete roll_jump command. Usage: roll_jump [league] """ return self.completion_helper.filter_completions( text, self.completion_helper.VALID_LEAGUES ) def complete_test_jump(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete test_jump command. Usage: test_jump [count] [league] """ parts = line.split() num_args = len(parts) - 1 # Exclude command name if num_args == 0 or (num_args == 1 and not text): # Suggest common counts common_counts = ['10', '50', '100', '500', '1000'] return self.completion_helper.filter_completions(text, common_counts) elif num_args == 1 or (num_args == 2 and not text): # Suggest leagues return self.completion_helper.filter_completions( text, self.completion_helper.VALID_LEAGUES ) return [] def complete_roll_fielding(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete roll_fielding command. Usage: roll_fielding [league] """ parts = line.split() num_args = len(parts) - 1 # Exclude command name if num_args == 0 or (num_args == 1 and not text): # Suggest positions return self.completion_helper.filter_completions( text, self.completion_helper.VALID_POSITIONS ) elif num_args == 1 or (num_args == 2 and not text): # Suggest leagues return self.completion_helper.filter_completions( text, self.completion_helper.VALID_LEAGUES ) return [] def complete_test_fielding(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete test_fielding command. Usage: test_fielding [count] [league] """ parts = line.split() num_args = len(parts) - 1 # Exclude command name if num_args == 0 or (num_args == 1 and not text): # Suggest positions return self.completion_helper.filter_completions( text, self.completion_helper.VALID_POSITIONS ) elif num_args == 1 or (num_args == 2 and not text): # Suggest common counts common_counts = ['10', '50', '100', '500', '1000'] return self.completion_helper.filter_completions(text, common_counts) elif num_args == 2 or (num_args == 3 and not text): # Suggest leagues return self.completion_helper.filter_completions( text, self.completion_helper.VALID_LEAGUES ) return [] # ==================== Helper Methods ==================== def completedefault(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Default completion handler for commands without specific completers. Provides basic option completion if line contains options. """ # If text starts with --, try to show common options if text.startswith('--'): common_options = ['help', 'verbose', 'debug'] return self.completion_helper.complete_option(text, line, common_options) return [] def complete_resolve_with(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """ Complete resolve_with command with PlayOutcome values. Usage: resolve_with Provides completions for all valid PlayOutcome enum values. """ # Complete the outcome argument (first and only argument) return self.completion_helper.filter_completions( text, self.completion_helper.VALID_OUTCOMES ) def completenames(self, text: str, *ignored) -> List[str]: """ Override completenames to provide better command completion. This is called when completing the first word (command name). """ # Get all do_* methods dotext = 'do_' + text commands = [name[3:] for name in self.get_names() if name.startswith(dotext)] # Add aliases if 'exit'.startswith(text): commands.append('exit') if 'quit'.startswith(text): commands.append('quit') return commands # Example completion mappings for reference COMPLETION_EXAMPLES = """ # Example Tab Completion Usage: ⚾ > new_game -- --league --home-team --away-team ⚾ > new_game --league sba pd ⚾ > defensive -- --alignment --infield --outfield --hold ⚾ > defensive --alignment normal shifted_left shifted_right extreme_shift ⚾ > defensive --hold 1, 1,2 1,3 ⚾ > offensive --approach normal contact power patient ⚾ > use_game [shows all active game UUIDs] ⚾ > quick_play 1 5 10 27 50 100 """