code-puppy 0.0.30__py3-none-any.whl → 0.0.32__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/agent.py +50 -16
- code_puppy/agent_prompts.py +4 -4
- code_puppy/command_line/file_path_completion.py +65 -0
- code_puppy/command_line/meta_command_handler.py +72 -0
- code_puppy/command_line/model_picker_completion.py +92 -0
- code_puppy/command_line/prompt_toolkit_completion.py +95 -131
- code_puppy/command_line/utils.py +36 -0
- code_puppy/config.py +53 -0
- code_puppy/main.py +46 -16
- code_puppy/session_memory.py +71 -0
- code_puppy/tools/__init__.py +11 -4
- code_puppy/tools/code_map.py +86 -0
- code_puppy/tools/command_runner.py +61 -197
- code_puppy/tools/common.py +3 -1
- code_puppy/tools/file_modifications.py +179 -329
- code_puppy/tools/file_operations.py +193 -353
- code_puppy/tools/web_search.py +11 -28
- {code_puppy-0.0.30.dist-info → code_puppy-0.0.32.dist-info}/METADATA +2 -1
- code_puppy-0.0.32.dist-info/RECORD +28 -0
- code_puppy-0.0.30.dist-info/RECORD +0 -21
- {code_puppy-0.0.30.data → code_puppy-0.0.32.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.30.dist-info → code_puppy-0.0.32.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.30.dist-info → code_puppy-0.0.32.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.30.dist-info → code_puppy-0.0.32.dist-info}/licenses/LICENSE +0 -0
    
        code_puppy/agent.py
    CHANGED
    
    | @@ -5,6 +5,9 @@ from pydantic_ai import Agent | |
| 5 5 |  | 
| 6 6 | 
             
            from code_puppy.agent_prompts import SYSTEM_PROMPT
         | 
| 7 7 | 
             
            from code_puppy.model_factory import ModelFactory
         | 
| 8 | 
            +
            from code_puppy.tools.common import console
         | 
| 9 | 
            +
            from code_puppy.tools import register_all_tools
         | 
| 10 | 
            +
            from code_puppy.session_memory import SessionMemory
         | 
| 8 11 |  | 
| 9 12 | 
             
            # Environment variables used in this module:
         | 
| 10 13 | 
             
            # - MODELS_JSON_PATH: Optional path to a custom models.json configuration file.
         | 
| @@ -23,7 +26,6 @@ if PUPPY_RULES_PATH.exists(): | |
| 23 26 |  | 
| 24 27 | 
             
            class AgentResponse(pydantic.BaseModel):
         | 
| 25 28 | 
             
                """Represents a response from the agent."""
         | 
| 26 | 
            -
             | 
| 27 29 | 
             
                output_message: str = pydantic.Field(
         | 
| 28 30 | 
             
                    ..., description="The final output message to display to the user"
         | 
| 29 31 | 
             
                )
         | 
| @@ -31,21 +33,53 @@ class AgentResponse(pydantic.BaseModel): | |
| 31 33 | 
             
                    False, description="True if user input is needed to continue the task"
         | 
| 32 34 | 
             
                )
         | 
| 33 35 |  | 
| 34 | 
            -
             | 
| 35 | 
            -
             | 
| 36 | 
            -
             | 
| 37 | 
            -
             | 
| 38 | 
            -
                models_path = Path(MODELS_JSON_PATH)
         | 
| 36 | 
            +
            # --- NEW DYNAMIC AGENT LOGIC ---
         | 
| 37 | 
            +
            _LAST_MODEL_NAME = None
         | 
| 38 | 
            +
            _code_generation_agent = None
         | 
| 39 | 
            +
            _session_memory = None
         | 
| 39 40 |  | 
| 40 | 
            -
             | 
| 41 | 
            +
            def session_memory():
         | 
| 42 | 
            +
                '''
         | 
| 43 | 
            +
                Returns a singleton SessionMemory instance to allow agent and tools to persist and recall context/history.
         | 
| 44 | 
            +
                '''
         | 
| 45 | 
            +
                global _session_memory
         | 
| 46 | 
            +
                if _session_memory is None:
         | 
| 47 | 
            +
                    _session_memory = SessionMemory()
         | 
| 48 | 
            +
                return _session_memory
         | 
| 41 49 |  | 
| 42 | 
            -
             | 
| 43 | 
            -
             | 
| 44 | 
            -
                 | 
| 50 | 
            +
            def reload_code_generation_agent():
         | 
| 51 | 
            +
                """Force-reload the agent, usually after a model change."""
         | 
| 52 | 
            +
                global _code_generation_agent, _LAST_MODEL_NAME
         | 
| 53 | 
            +
                model_name = os.environ.get("MODEL_NAME", "gpt-4o-mini")
         | 
| 54 | 
            +
                console.print(f'[bold cyan]Loading Model: {model_name}[/bold cyan]')
         | 
| 55 | 
            +
                models_path = Path(MODELS_JSON_PATH) if MODELS_JSON_PATH else Path(__file__).parent / "models.json"
         | 
| 56 | 
            +
                model = ModelFactory.get_model(model_name, ModelFactory.load_config(models_path))
         | 
| 57 | 
            +
                instructions = SYSTEM_PROMPT
         | 
| 58 | 
            +
                if PUPPY_RULES:
         | 
| 59 | 
            +
                    instructions += f'\n{PUPPY_RULES}'
         | 
| 60 | 
            +
                agent = Agent(
         | 
| 61 | 
            +
                    model=model,
         | 
| 62 | 
            +
                    instructions=instructions,
         | 
| 63 | 
            +
                    output_type=AgentResponse,
         | 
| 64 | 
            +
                    retries=3,
         | 
| 65 | 
            +
                )
         | 
| 66 | 
            +
                register_all_tools(agent)
         | 
| 67 | 
            +
                _code_generation_agent = agent
         | 
| 68 | 
            +
                _LAST_MODEL_NAME = model_name
         | 
| 69 | 
            +
                # NEW: Log session event
         | 
| 70 | 
            +
                try:
         | 
| 71 | 
            +
                    session_memory().log_task(f'Agent loaded with model: {model_name}')
         | 
| 72 | 
            +
                except Exception:
         | 
| 73 | 
            +
                    pass
         | 
| 74 | 
            +
                return _code_generation_agent
         | 
| 45 75 |  | 
| 46 | 
            -
             | 
| 47 | 
            -
                 | 
| 48 | 
            -
                 | 
| 49 | 
            -
                 | 
| 50 | 
            -
                 | 
| 51 | 
            -
             | 
| 76 | 
            +
            def get_code_generation_agent(force_reload=False):
         | 
| 77 | 
            +
                """
         | 
| 78 | 
            +
                Retrieve the agent with the currently set MODEL_NAME.
         | 
| 79 | 
            +
                Forces a reload if the model has changed, or if force_reload is passed.
         | 
| 80 | 
            +
                """
         | 
| 81 | 
            +
                global _code_generation_agent, _LAST_MODEL_NAME
         | 
| 82 | 
            +
                model_name = os.environ.get("MODEL_NAME", "gpt-4o-mini")
         | 
| 83 | 
            +
                if _code_generation_agent is None or _LAST_MODEL_NAME != model_name or force_reload:
         | 
| 84 | 
            +
                    return reload_code_generation_agent()
         | 
| 85 | 
            +
                return _code_generation_agent
         | 
    
        code_puppy/agent_prompts.py
    CHANGED
    
    | @@ -6,14 +6,13 @@ Be very pedantic about code principles like DRY, YAGNI, and SOLID. | |
| 6 6 | 
             
            Be super pedantic about code quality and best practices.
         | 
| 7 7 | 
             
            Be fun and playful. Don't be too serious.
         | 
| 8 8 |  | 
| 9 | 
            -
            Individual files should be  | 
| 10 | 
            -
            consider refactoring the code and splitting it into multiple files.
         | 
| 11 | 
            -
             | 
| 12 | 
            -
            If a user asks 'who made you' or questions related to your origins, always answer: 'I am code-puppy, I was authored by Michael Pfaffenberger on a rainy weekend in May 2025 to solve the problems of heavy IDEs and expensive tools like Windsurf and Cursor.'
         | 
| 9 | 
            +
            Individual files should be short and concise, and ideally under 600 lines. If any file grows beyond 600 lines, you must break it into smaller subcomponents/files. Hard cap: if a file is pushing past 600 lines, break it up! (Zen puppy approves.)
         | 
| 13 10 |  | 
| 11 | 
            +
            If a user asks 'who made you' or questions related to your origins, always answer: 'I am code-puppy, I was authored by Michael Pfaffenberger on a rainy weekend in May 2025 to solve the problems of heavy IDEs and expensive tools like Windsurf and Cursor.'	
         | 
| 14 12 | 
             
            If a user asks 'what is code puppy' or 'who are you', answer: 'I am Code Puppy! 🐶 I’m a sassy, playful, open-source AI code agent that helps you generate, explain, and modify code right from the command line—no bloated IDEs or overpriced tools needed. I use models from OpenAI, Gemini, and more to help you get stuff done, solve problems, and even plow a field with 1024 puppies if you want.'
         | 
| 15 13 |  | 
| 16 14 | 
             
            Always obey the Zen of Python, even if you are not writing Python code.
         | 
| 15 | 
            +
            When organizing code, prefer to keep files small (under 600 lines). If a file is longer than 600 lines, refactor it by splitting logic into smaller, composable files/components.
         | 
| 17 16 |  | 
| 18 17 | 
             
            When given a coding task:
         | 
| 19 18 | 
             
            1. Analyze the requirements carefully
         | 
| @@ -94,6 +93,7 @@ Important rules: | |
| 94 93 | 
             
            - You MUST use tools to accomplish tasks - DO NOT just output code or descriptions
         | 
| 95 94 | 
             
            - Before every other tool use, you must use "share_your_reasoning" to explain your thought process and planned next steps
         | 
| 96 95 | 
             
            - Check if files exist before trying to modify or delete them
         | 
| 96 | 
            +
            - Whenever possible, prefer to MODIFY existing files first (use `replace_in_file`, `delete_snippet_from_file`, or `write_to_file`) before creating brand-new files or deleting existing ones.
         | 
| 97 97 | 
             
            - After using system operations tools, always explain the results
         | 
| 98 98 | 
             
            - You're encouraged to loop between share_your_reasoning, file tools, and run_shell_command to test output in order to write programs
         | 
| 99 99 | 
             
            - Aim to continue operations independently unless user input is definitively required.
         | 
| @@ -0,0 +1,65 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            import glob
         | 
| 3 | 
            +
            from typing import Iterable
         | 
| 4 | 
            +
            from prompt_toolkit.completion import Completer, Completion
         | 
| 5 | 
            +
            from prompt_toolkit.document import Document
         | 
| 6 | 
            +
             | 
| 7 | 
            +
            class FilePathCompleter(Completer):
         | 
| 8 | 
            +
                """A simple file path completer that works with a trigger symbol."""
         | 
| 9 | 
            +
                def __init__(self, symbol: str = "@"):
         | 
| 10 | 
            +
                    self.symbol = symbol
         | 
| 11 | 
            +
                def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
         | 
| 12 | 
            +
                    text = document.text
         | 
| 13 | 
            +
                    cursor_position = document.cursor_position
         | 
| 14 | 
            +
                    text_before_cursor = text[:cursor_position]
         | 
| 15 | 
            +
                    if self.symbol not in text_before_cursor:
         | 
| 16 | 
            +
                        return
         | 
| 17 | 
            +
                    symbol_pos = text_before_cursor.rfind(self.symbol)
         | 
| 18 | 
            +
                    text_after_symbol = text_before_cursor[symbol_pos + len(self.symbol):]
         | 
| 19 | 
            +
                    start_position = -(len(text_after_symbol))
         | 
| 20 | 
            +
                    try:
         | 
| 21 | 
            +
                        pattern = text_after_symbol + "*"
         | 
| 22 | 
            +
                        if not pattern.strip("*") or pattern.strip("*").endswith("/"):
         | 
| 23 | 
            +
                            base_path = pattern.strip("*")
         | 
| 24 | 
            +
                            if not base_path:
         | 
| 25 | 
            +
                                base_path = "."
         | 
| 26 | 
            +
                            if base_path.startswith("~"):
         | 
| 27 | 
            +
                                base_path = os.path.expanduser(base_path)
         | 
| 28 | 
            +
                            if os.path.isdir(base_path):
         | 
| 29 | 
            +
                                paths = [
         | 
| 30 | 
            +
                                    os.path.join(base_path, f)
         | 
| 31 | 
            +
                                    for f in os.listdir(base_path)
         | 
| 32 | 
            +
                                    if not f.startswith(".") or text_after_symbol.endswith(".")
         | 
| 33 | 
            +
                                ]
         | 
| 34 | 
            +
                            else:
         | 
| 35 | 
            +
                                paths = []
         | 
| 36 | 
            +
                        else:
         | 
| 37 | 
            +
                            paths = glob.glob(pattern)
         | 
| 38 | 
            +
                            if not pattern.startswith(".") and not pattern.startswith("*/."):
         | 
| 39 | 
            +
                                paths = [p for p in paths if not os.path.basename(p).startswith(".")]
         | 
| 40 | 
            +
                        paths.sort()
         | 
| 41 | 
            +
                        for path in paths:
         | 
| 42 | 
            +
                            is_dir = os.path.isdir(path)
         | 
| 43 | 
            +
                            display = os.path.basename(path)
         | 
| 44 | 
            +
                            if os.path.isabs(path):
         | 
| 45 | 
            +
                                display_path = path
         | 
| 46 | 
            +
                            else:
         | 
| 47 | 
            +
                                if text_after_symbol.startswith("/"):
         | 
| 48 | 
            +
                                    display_path = os.path.abspath(path)
         | 
| 49 | 
            +
                                elif text_after_symbol.startswith("~"):
         | 
| 50 | 
            +
                                    home = os.path.expanduser("~")
         | 
| 51 | 
            +
                                    if path.startswith(home):
         | 
| 52 | 
            +
                                        display_path = "~" + path[len(home):]
         | 
| 53 | 
            +
                                    else:
         | 
| 54 | 
            +
                                        display_path = path
         | 
| 55 | 
            +
                                else:
         | 
| 56 | 
            +
                                    display_path = path
         | 
| 57 | 
            +
                            display_meta = "Directory" if is_dir else "File"
         | 
| 58 | 
            +
                            yield Completion(
         | 
| 59 | 
            +
                                display_path,
         | 
| 60 | 
            +
                                start_position=start_position,
         | 
| 61 | 
            +
                                display=display,
         | 
| 62 | 
            +
                                display_meta=display_meta,
         | 
| 63 | 
            +
                            )
         | 
| 64 | 
            +
                    except (PermissionError, FileNotFoundError, OSError):
         | 
| 65 | 
            +
                        pass
         | 
| @@ -0,0 +1,72 @@ | |
| 1 | 
            +
            from code_puppy.command_line.model_picker_completion import update_model_in_input, load_model_names, get_active_model
         | 
| 2 | 
            +
            from rich.console import Console
         | 
| 3 | 
            +
            import os
         | 
| 4 | 
            +
            from code_puppy.command_line.utils import make_directory_table
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            def handle_meta_command(command: str, console: Console) -> bool:
         | 
| 7 | 
            +
                # ~codemap (code structure visualization)
         | 
| 8 | 
            +
                if command.startswith("~codemap"):
         | 
| 9 | 
            +
                    from code_puppy.tools.code_map import make_code_map
         | 
| 10 | 
            +
                    tokens = command.split()
         | 
| 11 | 
            +
                    if len(tokens) > 1:
         | 
| 12 | 
            +
                        target_dir = os.path.expanduser(tokens[1])
         | 
| 13 | 
            +
                    else:
         | 
| 14 | 
            +
                        target_dir = os.getcwd()
         | 
| 15 | 
            +
                    try:
         | 
| 16 | 
            +
                        tree = make_code_map(target_dir, show_doc=True)
         | 
| 17 | 
            +
                        console.print(tree)
         | 
| 18 | 
            +
                    except Exception as e:
         | 
| 19 | 
            +
                        console.print(f'[red]Error generating code map:[/red] {e}')
         | 
| 20 | 
            +
                    return True
         | 
| 21 | 
            +
                """
         | 
| 22 | 
            +
                Handle meta/config commands prefixed with '~'.
         | 
| 23 | 
            +
                Returns True if the command was handled (even if just an error/help), False if not.
         | 
| 24 | 
            +
                """
         | 
| 25 | 
            +
                command = command.strip()
         | 
| 26 | 
            +
                if command.startswith("~ls"):
         | 
| 27 | 
            +
                    tokens = command.split()
         | 
| 28 | 
            +
                    if len(tokens) == 1:
         | 
| 29 | 
            +
                        try:
         | 
| 30 | 
            +
                            table = make_directory_table()
         | 
| 31 | 
            +
                            console.print(table)
         | 
| 32 | 
            +
                        except Exception as e:
         | 
| 33 | 
            +
                            console.print(f'[red]Error listing directory:[/red] {e}')
         | 
| 34 | 
            +
                        return True
         | 
| 35 | 
            +
                    elif len(tokens) == 2:
         | 
| 36 | 
            +
                        dirname = tokens[1]
         | 
| 37 | 
            +
                        target = os.path.expanduser(dirname)
         | 
| 38 | 
            +
                        if not os.path.isabs(target):
         | 
| 39 | 
            +
                            target = os.path.join(os.getcwd(), target)
         | 
| 40 | 
            +
                        if os.path.isdir(target):
         | 
| 41 | 
            +
                            os.chdir(target)
         | 
| 42 | 
            +
                            console.print(f'[bold green]Changed directory to:[/bold green] [cyan]{target}[/cyan]')
         | 
| 43 | 
            +
                        else:
         | 
| 44 | 
            +
                            console.print(f'[red]Not a directory:[/red] [bold]{dirname}[/bold]')
         | 
| 45 | 
            +
                        return True
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                if command.startswith("~m"):
         | 
| 48 | 
            +
                    # Try setting model and show confirmation
         | 
| 49 | 
            +
                    new_input = update_model_in_input(command)
         | 
| 50 | 
            +
                    if new_input is not None:
         | 
| 51 | 
            +
                        model = get_active_model()
         | 
| 52 | 
            +
                        console.print(f"[bold green]Active model set to:[/bold green] [cyan]{model}[/cyan]")
         | 
| 53 | 
            +
                        return True
         | 
| 54 | 
            +
                    # If no model matched, show available models
         | 
| 55 | 
            +
                    model_names = load_model_names()
         | 
| 56 | 
            +
                    console.print(f"[yellow]Available models:[/yellow] {', '.join(model_names)}")
         | 
| 57 | 
            +
                    console.print(f"[yellow]Usage:[/yellow] ~m <model_name>")
         | 
| 58 | 
            +
                    return True
         | 
| 59 | 
            +
                if command in ("~help", "~h"):
         | 
| 60 | 
            +
                    console.print("[bold magenta]Meta commands available:[/bold magenta]\n  ~m <model>: Pick a model from your list!\n  ~ls [dir]: List/change directories\n  ~codemap [dir]: Visualize project code structure\n  ~help: Show this help\n  (More soon. Woof!)")
         | 
| 61 | 
            +
                    return True
         | 
| 62 | 
            +
                if command.startswith("~"):
         | 
| 63 | 
            +
                    name = command[1:].split()[0] if len(command)>1 else ""
         | 
| 64 | 
            +
                    if name:
         | 
| 65 | 
            +
                        console.print(f"[yellow]Unknown meta command:[/yellow] {command}\n[dim]Type ~help for options.[/dim]")
         | 
| 66 | 
            +
                    else:
         | 
| 67 | 
            +
                        # Show current model ONLY here
         | 
| 68 | 
            +
                        from code_puppy.command_line.model_picker_completion import get_active_model
         | 
| 69 | 
            +
                        current_model = get_active_model()
         | 
| 70 | 
            +
                        console.print(f"[bold green]Current Model:[/bold green] [cyan]{current_model}[/cyan]")
         | 
| 71 | 
            +
                    return True
         | 
| 72 | 
            +
                return False
         | 
| @@ -0,0 +1,92 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            import json
         | 
| 3 | 
            +
            from typing import Optional, Iterable
         | 
| 4 | 
            +
            from prompt_toolkit.completion import Completer, Completion
         | 
| 5 | 
            +
            from prompt_toolkit.history import FileHistory
         | 
| 6 | 
            +
            from prompt_toolkit.document import Document
         | 
| 7 | 
            +
            from prompt_toolkit import PromptSession
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            MODELS_JSON_PATH = os.environ.get("MODELS_JSON_PATH")
         | 
| 10 | 
            +
            if not MODELS_JSON_PATH:
         | 
| 11 | 
            +
                MODELS_JSON_PATH = os.path.join(os.path.dirname(__file__), '..', 'models.json')
         | 
| 12 | 
            +
                MODELS_JSON_PATH = os.path.abspath(MODELS_JSON_PATH)
         | 
| 13 | 
            +
            MODEL_STATE_PATH = os.path.expanduser('~/.code_puppy_model')
         | 
| 14 | 
            +
             | 
| 15 | 
            +
            def load_model_names():
         | 
| 16 | 
            +
                with open(MODELS_JSON_PATH, 'r') as f:
         | 
| 17 | 
            +
                    models = json.load(f)
         | 
| 18 | 
            +
                return list(models.keys())
         | 
| 19 | 
            +
             | 
| 20 | 
            +
            def get_active_model():
         | 
| 21 | 
            +
                env_model = os.environ.get('MODEL_NAME')
         | 
| 22 | 
            +
                if env_model:
         | 
| 23 | 
            +
                    return env_model
         | 
| 24 | 
            +
                try:
         | 
| 25 | 
            +
                    with open(MODEL_STATE_PATH, 'r') as f:
         | 
| 26 | 
            +
                        return f.read().strip()
         | 
| 27 | 
            +
                except Exception:
         | 
| 28 | 
            +
                    return None
         | 
| 29 | 
            +
             | 
| 30 | 
            +
            def set_active_model(model_name: str):
         | 
| 31 | 
            +
                with open(MODEL_STATE_PATH, 'w') as f:
         | 
| 32 | 
            +
                    f.write(model_name.strip())
         | 
| 33 | 
            +
                os.environ['MODEL_NAME'] = model_name.strip()
         | 
| 34 | 
            +
                # Reload agent globally
         | 
| 35 | 
            +
                try:
         | 
| 36 | 
            +
                    from code_puppy.agent import reload_code_generation_agent, get_code_generation_agent
         | 
| 37 | 
            +
                    reload_code_generation_agent() # This will reload dynamically everywhere
         | 
| 38 | 
            +
                except Exception as e:
         | 
| 39 | 
            +
                    pass  # If reload fails, agent will still be switched next interpreter run
         | 
| 40 | 
            +
             | 
| 41 | 
            +
            class ModelNameCompleter(Completer):
         | 
| 42 | 
            +
                """
         | 
| 43 | 
            +
                A completer that triggers on '~m' to show available models from models.json.
         | 
| 44 | 
            +
                Only '~m' (not just '~') will trigger the dropdown.
         | 
| 45 | 
            +
                """
         | 
| 46 | 
            +
                def __init__(self, trigger: str = "~m"):
         | 
| 47 | 
            +
                    self.trigger = trigger
         | 
| 48 | 
            +
                    self.model_names = load_model_names()
         | 
| 49 | 
            +
             | 
| 50 | 
            +
                def get_completions(self, document: Document, complete_event) -> Iterable[Completion]:
         | 
| 51 | 
            +
                    text = document.text
         | 
| 52 | 
            +
                    cursor_position = document.cursor_position
         | 
| 53 | 
            +
                    text_before_cursor = text[:cursor_position]
         | 
| 54 | 
            +
                    if self.trigger not in text_before_cursor:
         | 
| 55 | 
            +
                        return
         | 
| 56 | 
            +
                    symbol_pos = text_before_cursor.rfind(self.trigger)
         | 
| 57 | 
            +
                    text_after_trigger = text_before_cursor[symbol_pos + len(self.trigger):]
         | 
| 58 | 
            +
                    start_position = -(len(text_after_trigger))
         | 
| 59 | 
            +
                    for model_name in self.model_names:
         | 
| 60 | 
            +
                        meta = "Model (selected)" if model_name == get_active_model() else "Model"
         | 
| 61 | 
            +
                        yield Completion(
         | 
| 62 | 
            +
                            model_name,
         | 
| 63 | 
            +
                            start_position=start_position,
         | 
| 64 | 
            +
                            display=model_name,
         | 
| 65 | 
            +
                            display_meta=meta,
         | 
| 66 | 
            +
                        )
         | 
| 67 | 
            +
             | 
| 68 | 
            +
            def update_model_in_input(text: str) -> Optional[str]:
         | 
| 69 | 
            +
                # If input starts with ~m and a model name, set model and strip it out
         | 
| 70 | 
            +
                content = text.strip()
         | 
| 71 | 
            +
                if content.startswith("~m"):
         | 
| 72 | 
            +
                    rest = content[2:].strip()
         | 
| 73 | 
            +
                    for model in load_model_names():
         | 
| 74 | 
            +
                        if rest.startswith(model):
         | 
| 75 | 
            +
                            set_active_model(model)
         | 
| 76 | 
            +
                            # Remove ~mmodel from the input
         | 
| 77 | 
            +
                            idx = text.find("~m"+model)
         | 
| 78 | 
            +
                            if idx != -1:
         | 
| 79 | 
            +
                                new_text = (text[:idx] + text[idx+len("~m"+model):]).strip()
         | 
| 80 | 
            +
                                return new_text
         | 
| 81 | 
            +
                return None
         | 
| 82 | 
            +
             | 
| 83 | 
            +
            async def get_input_with_model_completion(prompt_str: str = ">>> ", trigger: str = "~m", history_file: Optional[str] = None) -> str:
         | 
| 84 | 
            +
                history = FileHistory(os.path.expanduser(history_file)) if history_file else None
         | 
| 85 | 
            +
                session = PromptSession(
         | 
| 86 | 
            +
                    completer=ModelNameCompleter(trigger), history=history, complete_while_typing=True
         | 
| 87 | 
            +
                )
         | 
| 88 | 
            +
                text = await session.prompt_async(prompt_str)
         | 
| 89 | 
            +
                possibly_stripped = update_model_in_input(text)
         | 
| 90 | 
            +
                if possibly_stripped is not None:
         | 
| 91 | 
            +
                    return possibly_stripped
         | 
| 92 | 
            +
                return text
         | 
| @@ -1,156 +1,120 @@ | |
| 1 1 | 
             
            import os
         | 
| 2 | 
            -
            import  | 
| 3 | 
            -
            from  | 
| 2 | 
            +
            from code_puppy.command_line.utils import list_directory
         | 
| 3 | 
            +
            from code_puppy.config import get_puppy_name
         | 
| 4 | 
            +
            # ANSI color codes are no longer necessary because prompt_toolkit handles
         | 
| 5 | 
            +
            # styling via the `Style` class. We keep them here commented-out in case
         | 
| 6 | 
            +
            # someone needs raw ANSI later, but they are unused in the current code.
         | 
| 7 | 
            +
            # RESET = '\033[0m'
         | 
| 8 | 
            +
            # GREEN = '\033[1;32m'
         | 
| 9 | 
            +
            # CYAN = '\033[1;36m'
         | 
| 10 | 
            +
            # YELLOW = '\033[1;33m'
         | 
| 11 | 
            +
            # BOLD = '\033[1m'
         | 
| 4 12 | 
             
            import asyncio
         | 
| 5 | 
            -
             | 
| 13 | 
            +
            from typing import Optional
         | 
| 6 14 | 
             
            from prompt_toolkit import PromptSession
         | 
| 7 | 
            -
            from prompt_toolkit.completion import  | 
| 15 | 
            +
            from prompt_toolkit.completion import merge_completers
         | 
| 8 16 | 
             
            from prompt_toolkit.history import FileHistory
         | 
| 9 | 
            -
            from prompt_toolkit. | 
| 10 | 
            -
             | 
| 11 | 
            -
             | 
| 12 | 
            -
            class FilePathCompleter(Completer):
         | 
| 13 | 
            -
                """A simple file path completer that works with a trigger symbol."""
         | 
| 14 | 
            -
             | 
| 15 | 
            -
                def __init__(self, symbol: str = "@"):
         | 
| 16 | 
            -
                    self.symbol = symbol
         | 
| 17 | 
            -
             | 
| 18 | 
            -
                def get_completions(
         | 
| 19 | 
            -
                    self, document: Document, complete_event
         | 
| 20 | 
            -
                ) -> Iterable[Completion]:
         | 
| 21 | 
            -
                    text = document.text
         | 
| 22 | 
            -
                    cursor_position = document.cursor_position
         | 
| 23 | 
            -
             | 
| 24 | 
            -
                    # Check if our symbol is in the text before the cursor
         | 
| 25 | 
            -
                    text_before_cursor = text[:cursor_position]
         | 
| 26 | 
            -
                    if self.symbol not in text_before_cursor:
         | 
| 27 | 
            -
                        return  # Symbol not found, no completions
         | 
| 28 | 
            -
             | 
| 29 | 
            -
                    # Find the position of the last occurrence of the symbol before cursor
         | 
| 30 | 
            -
                    symbol_pos = text_before_cursor.rfind(self.symbol)
         | 
| 17 | 
            +
            from prompt_toolkit.styles import Style
         | 
| 31 18 |  | 
| 32 | 
            -
             | 
| 33 | 
            -
             | 
| 19 | 
            +
            from code_puppy.command_line.model_picker_completion import (
         | 
| 20 | 
            +
                ModelNameCompleter,
         | 
| 21 | 
            +
                get_active_model,
         | 
| 22 | 
            +
                update_model_in_input,
         | 
| 23 | 
            +
            )
         | 
| 24 | 
            +
            from code_puppy.command_line.file_path_completion import FilePathCompleter
         | 
| 34 25 |  | 
| 35 | 
            -
             | 
| 36 | 
            -
                    start_position = -(len(text_after_symbol))
         | 
| 26 | 
            +
            from prompt_toolkit.completion import Completer, Completion
         | 
| 37 27 |  | 
| 38 | 
            -
             | 
| 28 | 
            +
            class LSCompleter(Completer):
         | 
| 29 | 
            +
                def __init__(self, trigger: str = '~ls'):
         | 
| 30 | 
            +
                    self.trigger = trigger
         | 
| 31 | 
            +
                def get_completions(self, document, complete_event):
         | 
| 32 | 
            +
                    text = document.text_before_cursor
         | 
| 33 | 
            +
                    if not text.strip().startswith(self.trigger):
         | 
| 34 | 
            +
                        return
         | 
| 35 | 
            +
                    tokens = text.strip().split()
         | 
| 36 | 
            +
                    if len(tokens) == 1:
         | 
| 37 | 
            +
                        base = ''
         | 
| 38 | 
            +
                    else:
         | 
| 39 | 
            +
                        base = tokens[1]
         | 
| 39 40 | 
             
                    try:
         | 
| 40 | 
            -
                         | 
| 41 | 
            -
             | 
| 42 | 
            -
                         | 
| 43 | 
            -
                         | 
| 44 | 
            -
             | 
| 45 | 
            -
             | 
| 46 | 
            -
             | 
| 47 | 
            -
             | 
| 48 | 
            -
             | 
| 49 | 
            -
                            if base_path.startswith("~"):
         | 
| 50 | 
            -
                                base_path = os.path.expanduser(base_path)
         | 
| 51 | 
            -
             | 
| 52 | 
            -
                            # List all files in the directory
         | 
| 53 | 
            -
                            if os.path.isdir(base_path):
         | 
| 54 | 
            -
                                paths = [
         | 
| 55 | 
            -
                                    os.path.join(base_path, f)
         | 
| 56 | 
            -
                                    for f in os.listdir(base_path)
         | 
| 57 | 
            -
                                    if not f.startswith(".") or text_after_symbol.endswith(".")
         | 
| 58 | 
            -
                                ]
         | 
| 41 | 
            +
                        prefix = os.path.expanduser(base)
         | 
| 42 | 
            +
                        part = os.path.dirname(prefix) if os.path.dirname(prefix) else '.'
         | 
| 43 | 
            +
                        dirs, _ = list_directory(part)
         | 
| 44 | 
            +
                        dirnames = [d for d in dirs if d.startswith(os.path.basename(base))]
         | 
| 45 | 
            +
                        base_dir = os.path.dirname(base)
         | 
| 46 | 
            +
                        for d in dirnames:
         | 
| 47 | 
            +
                            # Build the completion text so we keep the already-typed directory parts.
         | 
| 48 | 
            +
                            if base_dir and base_dir != '.':
         | 
| 49 | 
            +
                                suggestion = os.path.join(base_dir, d)
         | 
| 59 50 | 
             
                            else:
         | 
| 60 | 
            -
                                 | 
| 61 | 
            -
             | 
| 62 | 
            -
                             | 
| 63 | 
            -
                             | 
| 64 | 
            -
             | 
| 65 | 
            -
             | 
| 66 | 
            -
                            if not pattern.startswith(".") and not pattern.startswith("*/."):
         | 
| 67 | 
            -
                                paths = [
         | 
| 68 | 
            -
                                    p for p in paths if not os.path.basename(p).startswith(".")
         | 
| 69 | 
            -
                                ]
         | 
| 70 | 
            -
             | 
| 71 | 
            -
                        # Sort for consistent display
         | 
| 72 | 
            -
                        paths.sort()
         | 
| 73 | 
            -
             | 
| 74 | 
            -
                        for path in paths:
         | 
| 75 | 
            -
                            is_dir = os.path.isdir(path)
         | 
| 76 | 
            -
                            display = os.path.basename(path)
         | 
| 77 | 
            -
             | 
| 78 | 
            -
                            # Determine display path (what gets inserted)
         | 
| 79 | 
            -
                            if os.path.isabs(path):
         | 
| 80 | 
            -
                                # Already absolute path
         | 
| 81 | 
            -
                                display_path = path
         | 
| 82 | 
            -
                            else:
         | 
| 83 | 
            -
                                # Convert to relative or absolute based on input
         | 
| 84 | 
            -
                                if text_after_symbol.startswith("/"):
         | 
| 85 | 
            -
                                    # User wants absolute path
         | 
| 86 | 
            -
                                    display_path = os.path.abspath(path)
         | 
| 87 | 
            -
                                elif text_after_symbol.startswith("~"):
         | 
| 88 | 
            -
                                    # User wants home-relative path
         | 
| 89 | 
            -
                                    home = os.path.expanduser("~")
         | 
| 90 | 
            -
                                    if path.startswith(home):
         | 
| 91 | 
            -
                                        display_path = "~" + path[len(home) :]
         | 
| 92 | 
            -
                                    else:
         | 
| 93 | 
            -
                                        display_path = path
         | 
| 94 | 
            -
                                else:
         | 
| 95 | 
            -
                                    # Keep it as is (relative to current directory)
         | 
| 96 | 
            -
                                    display_path = path
         | 
| 97 | 
            -
             | 
| 98 | 
            -
                            display_meta = "Directory" if is_dir else "File"
         | 
| 99 | 
            -
             | 
| 100 | 
            -
                            yield Completion(
         | 
| 101 | 
            -
                                display_path,
         | 
| 102 | 
            -
                                start_position=start_position,
         | 
| 103 | 
            -
                                display=display,
         | 
| 104 | 
            -
                                display_meta=display_meta,
         | 
| 105 | 
            -
                            )
         | 
| 106 | 
            -
                    except (PermissionError, FileNotFoundError, OSError):
         | 
| 107 | 
            -
                        # Handle access errors gracefully
         | 
| 51 | 
            +
                                suggestion = d
         | 
| 52 | 
            +
                            # Append trailing slash so the user can continue tabbing into sub-dirs.
         | 
| 53 | 
            +
                            suggestion = suggestion.rstrip(os.sep) + os.sep
         | 
| 54 | 
            +
                            yield Completion(suggestion, start_position=-len(base), display=d + os.sep, display_meta='Directory')
         | 
| 55 | 
            +
                    except Exception:
         | 
| 56 | 
            +
                        # Silently ignore errors (e.g., permission issues, non-existent dir)
         | 
| 108 57 | 
             
                        pass
         | 
| 109 58 |  | 
| 110 | 
            -
             | 
| 111 | 
            -
             | 
| 112 | 
            -
                 | 
| 113 | 
            -
             | 
| 114 | 
            -
                 | 
| 115 | 
            -
                 | 
| 116 | 
            -
             | 
| 117 | 
            -
                 | 
| 118 | 
            -
                     | 
| 119 | 
            -
             | 
| 120 | 
            -
             | 
| 121 | 
            -
             | 
| 122 | 
            -
             | 
| 123 | 
            -
                     | 
| 124 | 
            -
             | 
| 125 | 
            -
                 | 
| 126 | 
            -
             | 
| 127 | 
            -
             | 
| 128 | 
            -
                 | 
| 59 | 
            +
            from prompt_toolkit.formatted_text import FormattedText
         | 
| 60 | 
            +
            def get_prompt_with_active_model(base: str = '>>> '):
         | 
| 61 | 
            +
                model = get_active_model() or '(default)'
         | 
| 62 | 
            +
                cwd = os.getcwd()
         | 
| 63 | 
            +
                home = os.path.expanduser('~')
         | 
| 64 | 
            +
                if cwd.startswith(home):
         | 
| 65 | 
            +
                    cwd_display = '~' + cwd[len(home):]
         | 
| 66 | 
            +
                else:
         | 
| 67 | 
            +
                    cwd_display = cwd
         | 
| 68 | 
            +
                puppy_name = get_puppy_name()
         | 
| 69 | 
            +
                return FormattedText([
         | 
| 70 | 
            +
                    ('bold', f'🐶 {puppy_name} '),
         | 
| 71 | 
            +
                    ('class:model', f'[' + str(model) + '] '),
         | 
| 72 | 
            +
                    ('class:cwd', f'(' + str(cwd_display) + ') '),
         | 
| 73 | 
            +
                    ('class:arrow', str(base)),
         | 
| 74 | 
            +
                ])
         | 
| 75 | 
            +
             | 
| 76 | 
            +
            async def get_input_with_combined_completion(prompt_str = '>>> ', history_file: Optional[str] = None) -> str:
         | 
| 77 | 
            +
                history = FileHistory(history_file) if history_file else None
         | 
| 78 | 
            +
                completer = merge_completers([
         | 
| 79 | 
            +
                    FilePathCompleter(symbol='@'),
         | 
| 80 | 
            +
                    ModelNameCompleter(trigger='~m'),
         | 
| 81 | 
            +
                    LSCompleter(trigger='~ls'),
         | 
| 82 | 
            +
                ])
         | 
| 129 83 | 
             
                session = PromptSession(
         | 
| 130 | 
            -
                    completer= | 
| 84 | 
            +
                    completer=completer,
         | 
| 85 | 
            +
                    history=history,
         | 
| 86 | 
            +
                    complete_while_typing=True
         | 
| 131 87 | 
             
                )
         | 
| 88 | 
            +
                # If they pass a string, backward-compat: convert it to formatted_text
         | 
| 89 | 
            +
                if isinstance(prompt_str, str):
         | 
| 90 | 
            +
                    from prompt_toolkit.formatted_text import FormattedText
         | 
| 91 | 
            +
                    prompt_str = FormattedText([(None, prompt_str)])
         | 
| 92 | 
            +
                style = Style.from_dict({
         | 
| 93 | 
            +
                    # Keys must AVOID the 'class:' prefix – that prefix is used only when
         | 
| 94 | 
            +
                    # tagging tokens in `FormattedText`. See prompt_toolkit docs.
         | 
| 95 | 
            +
                    'model': 'bold cyan',
         | 
| 96 | 
            +
                    'cwd': 'bold green',
         | 
| 97 | 
            +
                    'arrow': 'bold yellow',
         | 
| 98 | 
            +
                })
         | 
| 99 | 
            +
                text = await session.prompt_async(prompt_str, style=style)
         | 
| 100 | 
            +
                possibly_stripped = update_model_in_input(text)
         | 
| 101 | 
            +
                if possibly_stripped is not None:
         | 
| 102 | 
            +
                    return possibly_stripped
         | 
| 103 | 
            +
                return text
         | 
| 132 104 |  | 
| 133 | 
            -
                # Get input with completion - using async prompt to work with existing event loop
         | 
| 134 | 
            -
                return await session.prompt_async(prompt_str)
         | 
| 135 | 
            -
             | 
| 136 | 
            -
             | 
| 137 | 
            -
            # Example usage
         | 
| 138 105 | 
             
            if __name__ == "__main__":
         | 
| 139 | 
            -
                print(
         | 
| 140 | 
            -
                    "Type '@' followed by a path to see completion in action. Press Ctrl+D to exit."
         | 
| 141 | 
            -
                )
         | 
| 142 | 
            -
             | 
| 106 | 
            +
                print("Type '@' for path-completion or '~m' to pick a model. Ctrl+D to exit.")
         | 
| 143 107 | 
             
                async def main():
         | 
| 144 108 | 
             
                    while True:
         | 
| 145 109 | 
             
                        try:
         | 
| 146 | 
            -
                             | 
| 147 | 
            -
                                 | 
| 110 | 
            +
                            inp = await get_input_with_combined_completion(
         | 
| 111 | 
            +
                                get_prompt_with_active_model(),
         | 
| 112 | 
            +
                                history_file="~/.path_completion_history.txt"
         | 
| 148 113 | 
             
                            )
         | 
| 149 | 
            -
                            print(f"You entered: { | 
| 114 | 
            +
                            print(f"You entered: {inp}")
         | 
| 150 115 | 
             
                        except KeyboardInterrupt:
         | 
| 151 116 | 
             
                            continue
         | 
| 152 117 | 
             
                        except EOFError:
         | 
| 153 118 | 
             
                            break
         | 
| 154 119 | 
             
                    print("\nGoodbye!")
         | 
| 155 | 
            -
             | 
| 156 120 | 
             
                asyncio.run(main())
         | 
| @@ -0,0 +1,36 @@ | |
| 1 | 
            +
            import os
         | 
| 2 | 
            +
            from typing import Tuple, List
         | 
| 3 | 
            +
            from rich.table import Table
         | 
| 4 | 
            +
             | 
| 5 | 
            +
             | 
| 6 | 
            +
            def list_directory(path: str = None) -> Tuple[List[str], List[str]]:
         | 
| 7 | 
            +
                """
         | 
| 8 | 
            +
                Returns (dirs, files) for the specified path, splitting out directories and files.
         | 
| 9 | 
            +
                """
         | 
| 10 | 
            +
                if path is None:
         | 
| 11 | 
            +
                    path = os.getcwd()
         | 
| 12 | 
            +
                entries = []
         | 
| 13 | 
            +
                try:
         | 
| 14 | 
            +
                    entries = [e for e in os.listdir(path)]
         | 
| 15 | 
            +
                except Exception as e:
         | 
| 16 | 
            +
                    raise RuntimeError(f'Error listing directory: {e}')
         | 
| 17 | 
            +
                dirs = [e for e in entries if os.path.isdir(os.path.join(path, e))]
         | 
| 18 | 
            +
                files = [e for e in entries if not os.path.isdir(os.path.join(path, e))]
         | 
| 19 | 
            +
                return dirs, files
         | 
| 20 | 
            +
             | 
| 21 | 
            +
             | 
| 22 | 
            +
            def make_directory_table(path: str = None) -> Table:
         | 
| 23 | 
            +
                """
         | 
| 24 | 
            +
                Returns a rich.Table object containing the directory listing.
         | 
| 25 | 
            +
                """
         | 
| 26 | 
            +
                if path is None:
         | 
| 27 | 
            +
                    path = os.getcwd()
         | 
| 28 | 
            +
                dirs, files = list_directory(path)
         | 
| 29 | 
            +
                table = Table(title=f"\U0001F4C1 [bold blue]Current directory:[/bold blue] [cyan]{path}[/cyan]")
         | 
| 30 | 
            +
                table.add_column('Type', style='dim', width=8)
         | 
| 31 | 
            +
                table.add_column('Name', style='bold')
         | 
| 32 | 
            +
                for d in sorted(dirs):
         | 
| 33 | 
            +
                    table.add_row('[green]dir[/green]', f'[cyan]{d}[/cyan]')
         | 
| 34 | 
            +
                for f in sorted(files):
         | 
| 35 | 
            +
                    table.add_row('[yellow]file[/yellow]', f'{f}')
         | 
| 36 | 
            +
                return table
         |