● 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 --<TAB>
  --league  --home-team  --away-team

  ⚾ > new_game --league <TAB>
  sba  pd

  ⚾ > defensive --<TAB>
  --alignment  --infield  --outfield  --hold

  ⚾ > defensive --alignment <TAB>
  normal  shifted_left  shifted_right  extreme_shift

  ⚾ > defensive --hold 1,<TAB>
  1,2  1,3

  ⚾ > offensive --approach <TAB>
  normal  contact  power  patient

  ⚾ > use_game <TAB>
  [shows all active game UUIDs]

  ⚾ > quick_play <TAB>
  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 <command>' 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<TAB>              → defensive
  ⚾ > defensive --a<TAB>    → defensive --alignment
  ⚾ > defensive --alignment sh<TAB>  → defensive --alignment shifted_
  ⚾ > defensive --alignment shifted_<TAB>
  shifted_left  shifted_right

  ⚾ > off<TAB>              → offensive
  ⚾ > offensive --app<TAB>  → offensive --approach
  ⚾ > offensive --approach p<TAB>
  power  patient

  ⚾ > new_game --<TAB>
  --league  --home-team  --away-team

  ⚾ > use_game <TAB>
  [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
