This commit includes cleanup from model refactoring and terminal client
modularization for better code organization and maintainability.
## Game Models Refactor
**Removed RunnerState class:**
- Eliminated separate RunnerState model (was redundant)
- Replaced runners: List[RunnerState] with direct base references:
- on_first: Optional[LineupPlayerState]
- on_second: Optional[LineupPlayerState]
- on_third: Optional[LineupPlayerState]
- Updated helper methods:
- get_runner_at_base() now returns LineupPlayerState directly
- get_all_runners() returns List[Tuple[int, LineupPlayerState]]
- is_runner_on_X() simplified to direct None checks
**Benefits:**
- Matches database structure (plays table has on_first_id, etc.)
- Simpler state management (direct references vs list management)
- Better type safety (LineupPlayerState vs generic runner)
- Easier to work with in game engine logic
**Updated files:**
- app/models/game_models.py - Removed RunnerState, updated GameState
- app/core/play_resolver.py - Use get_all_runners() instead of state.runners
- app/core/validators.py - Updated runner access patterns
- tests/unit/models/test_game_models.py - Updated test assertions
- tests/unit/core/test_play_resolver.py - Updated test data
- tests/unit/core/test_validators.py - Updated test data
## Terminal Client Refactor
**Modularization (DRY principle):**
Created separate modules for better code organization:
1. **terminal_client/commands.py** (10,243 bytes)
- Shared command functions for game operations
- Used by both CLI (main.py) and REPL (repl.py)
- Functions: submit_defensive_decision, submit_offensive_decision,
resolve_play, quick_play_sequence
- Single source of truth for command logic
2. **terminal_client/arg_parser.py** (7,280 bytes)
- Centralized argument parsing and validation
- Handles defensive/offensive decision arguments
- Validates formats (alignment, depths, hold runners, steal attempts)
3. **terminal_client/completions.py** (10,357 bytes)
- TAB completion support for REPL mode
- Command completions, option completions, dynamic completions
- Game ID completions, defensive/offensive option suggestions
4. **terminal_client/help_text.py** (10,839 bytes)
- Centralized help text and command documentation
- Detailed command descriptions
- Usage examples for all commands
**Updated main modules:**
- terminal_client/main.py - Simplified by using shared commands module
- terminal_client/repl.py - Cleaner with shared functions and completions
**Benefits:**
- DRY: Behavior consistent between CLI and REPL modes
- Maintainability: Changes in one place affect both interfaces
- Testability: Can test commands module independently
- Organization: Clear separation of concerns
## Documentation
**New files:**
- app/models/visual_model_relationships.md
- Visual documentation of model relationships
- Helps understand data flow between models
- terminal_client/update_docs/ (6 phase documentation files)
- Phased documentation for terminal client evolution
- Historical context for implementation decisions
## Tests
**New test files:**
- tests/unit/terminal_client/__init__.py
- tests/unit/terminal_client/test_arg_parser.py
- tests/unit/terminal_client/test_commands.py
- tests/unit/terminal_client/test_completions.py
- tests/unit/terminal_client/test_help_text.py
**Updated tests:**
- Integration tests updated for new runner model
- Unit tests updated for model changes
- All tests passing with new structure
## Summary
- ✅ Simplified game state model (removed RunnerState)
- ✅ Better alignment with database structure
- ✅ Modularized terminal client (DRY principle)
- ✅ Shared command logic between CLI and REPL
- ✅ Comprehensive test coverage
- ✅ Improved documentation
Total changes: 26 files modified/created
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
558 lines
20 KiB
Markdown
558 lines
20 KiB
Markdown
● 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
|