● Terminal Client Improvement Plan - Part 3: Tab Completion Overview Add intelligent tab completion to the REPL for commands, options, and values. This significantly improves the developer experience by reducing typing and providing discovery of available options. Files to Create 1. Create backend/terminal_client/completions.py """ 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'] @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 name if text.startswith('--') or (not text and line.endswith(' ')): return self.completion_helper.complete_option(text, line, available_options) # Check if we're completing an option value 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 ) 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 name if text.startswith('--') or (not text and line.endswith(' ')): return self.completion_helper.complete_option(text, line, available_options) # Check if we're completing an option value 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 ) 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 name if text.startswith('--') or (not text and line.endswith(' ')): return self.completion_helper.complete_option(text, line, available_options) # Check if we're completing an option value 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 ) 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 [] # ==================== 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 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 """ Files to Update 2. Update backend/terminal_client/repl.py # Add import at the top from terminal_client.completions import GameREPLCompletions # Update class definition to include mixin class GameREPL(GameREPLCompletions, cmd.Cmd): """Interactive REPL for game engine testing.""" intro = """ ╔══════════════════════════════════════════════════════════════════════════════╗ ║ Paper Dynasty Game Engine - Terminal Client ║ ║ Interactive Mode ║ ╚══════════════════════════════════════════════════════════════════════════════╝ Type 'help' or '?' to list commands. Type 'help ' for command details. Type 'quit' or 'exit' to leave. Use TAB for auto-completion of commands and options. Quick start: new_game Create and start a new game with test lineups defensive Submit defensive decision offensive Submit offensive decision resolve Resolve the current play status Show current game state quick_play 10 Auto-play 10 plays Note: Use underscores in command names (e.g., 'new_game' not 'new-game') """ prompt = '⚾ > ' def __init__(self): # Initialize both parent classes cmd.Cmd.__init__(self) GameREPLCompletions.__init__(self) self.current_game_id: Optional[UUID] = None self.db_ops = DatabaseOperations() # Create persistent event loop for entire REPL session self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) # Try to load current game from config saved_game = Config.get_current_game() if saved_game: self.current_game_id = saved_game display.print_info(f"Loaded saved game: {saved_game}") # ... rest of the methods stay the same ... Testing Plan 3. Create backend/tests/unit/terminal_client/test_completions.py """ Unit tests for tab completion system. """ import pytest from unittest.mock import MagicMock, patch from terminal_client.completions import CompletionHelper, GameREPLCompletions class TestCompletionHelper: """Tests for CompletionHelper utility class.""" def test_filter_completions_exact_match(self): """Test filtering with exact match.""" options = ['apple', 'apricot', 'banana'] result = CompletionHelper.filter_completions('app', options) assert result == ['apple', 'apricot'] def test_filter_completions_no_match(self): """Test filtering with no matches.""" options = ['apple', 'apricot', 'banana'] result = CompletionHelper.filter_completions('cherry', options) assert result == [] def test_filter_completions_empty_text(self): """Test filtering with empty text returns all.""" options = ['apple', 'apricot', 'banana'] result = CompletionHelper.filter_completions('', options) assert result == options def test_complete_option_with_prefix(self): """Test completing option with -- prefix.""" available = ['league', 'home-team', 'away-team'] result = CompletionHelper.complete_option('--le', 'cmd --le', available) assert result == ['--league'] def test_complete_option_show_all(self): """Test showing all options when text is empty.""" available = ['league', 'home-team'] result = CompletionHelper.complete_option('', 'cmd ', available) assert set(result) == {'--league', '--home-team'} def test_get_current_option_simple(self): """Test getting current option from simple line.""" line = 'defensive --alignment ' result = CompletionHelper.get_current_option(line, len(line)) assert result == 'alignment' def test_get_current_option_multiple(self): """Test getting current option with multiple options.""" line = 'defensive --infield normal --alignment ' result = CompletionHelper.get_current_option(line, len(line)) assert result == 'alignment' def test_get_current_option_none(self): """Test getting current option when none present.""" line = 'defensive ' result = CompletionHelper.get_current_option(line, len(line)) assert result is None def test_get_current_option_hyphen_to_underscore(self): """Test option name converts hyphens to underscores.""" line = 'new_game --home-team ' result = CompletionHelper.get_current_option(line, len(line)) assert result == 'home_team' class TestGameREPLCompletions: """Tests for GameREPLCompletions mixin.""" @pytest.fixture def repl_completions(self): """Create GameREPLCompletions instance.""" return GameREPLCompletions() def test_complete_new_game_options(self, repl_completions): """Test completing new_game options.""" result = repl_completions.complete_new_game( '--', 'new_game --', 9, 11 ) assert '--league' in result assert '--home-team' in result assert '--away-team' in result def test_complete_new_game_league_value(self, repl_completions): """Test completing league value.""" result = repl_completions.complete_new_game( 's', 'new_game --league s', 9, 20 ) assert 'sba' in result assert 'pd' not in result def test_complete_defensive_alignment(self, repl_completions): """Test completing defensive alignment values.""" result = repl_completions.complete_defensive( 'shift', 'defensive --alignment shift', 10, 30 ) assert 'shifted_left' in result assert 'shifted_right' in result assert 'normal' not in result def test_complete_defensive_hold_bases(self, repl_completions): """Test completing hold bases.""" result = repl_completions.complete_defensive( '1,', 'defensive --hold 1,', 10, 19 ) assert '1,2' in result assert '1,3' in result def test_complete_offensive_approach(self, repl_completions): """Test completing offensive approach values.""" result = repl_completions.complete_offensive( 'p', 'offensive --approach p', 10, 22 ) assert 'power' in result assert 'patient' in result assert 'normal' not in result def test_complete_offensive_steal_bases(self, repl_completions): """Test completing steal bases.""" result = repl_completions.complete_offensive( '', 'offensive --steal ', 10, 18 ) assert '2' in result assert '3' in result assert '1' not in result # Can't steal first def test_complete_quick_play_counts(self, repl_completions): """Test completing quick_play with common counts.""" result = repl_completions.complete_quick_play( '1', 'quick_play 1', 11, 12 ) assert '1' in result assert '10' in result assert '100' in result @patch('terminal_client.completions.state_manager') def test_complete_use_game_with_games(self, mock_sm, repl_completions): """Test completing use_game with active games.""" from uuid import uuid4 game_id1 = uuid4() game_id2 = uuid4() mock_sm.list_games.return_value = [game_id1, game_id2] result = repl_completions.complete_use_game( str(game_id1)[:8], f'use_game {str(game_id1)[:8]}', 9, 17 ) # Should return the matching game ID assert any(str(game_id1) in r for r in result) User Experience Improvements Before (No Tab Completion): ⚾ > defensive --alignment shifted_left # Must type entire word ⚾ > offensive --approach power # Must remember exact spelling ⚾ > new_game --league pd --home-team 5 # Must know all options After (With Tab Completion): ⚾ > def → defensive ⚾ > defensive --a → defensive --alignment ⚾ > defensive --alignment sh → defensive --alignment shifted_ ⚾ > defensive --alignment shifted_ shifted_left shifted_right ⚾ > off → offensive ⚾ > offensive --app → offensive --approach ⚾ > offensive --approach p power patient ⚾ > new_game -- --league --home-team --away-team ⚾ > use_game [shows all active game IDs - can copy/paste or select] Benefits 1. Faster typing: Complete commands/options with fewer keystrokes 2. Discovery: See available options without checking help 3. Error prevention: Can't tab-complete to invalid values 4. Better UX: Feels like a professional CLI tool (like git, docker, etc.) 5. Learn by doing: Users discover options through exploration