diff --git a/code_puppy/plugins/prompt_newline/__init__.py b/code_puppy/plugins/prompt_newline/__init__.py new file mode 100644 index 000000000..1a6e61d2d --- /dev/null +++ b/code_puppy/plugins/prompt_newline/__init__.py @@ -0,0 +1,13 @@ +"""Plugin: drop user input onto a fresh line below the prompt chrome. + +When enabled, transforms + + 🐶 puppy [agent] [model] (~/very/long/cwd) >>> typed text + +into + + 🐶 puppy [agent] [model] (~/very/long/cwd) >>> + typed text + +Toggle at runtime with ``/prompt_newline [on|off]``. Persisted in puppy.cfg. +""" diff --git a/code_puppy/plugins/prompt_newline/config.py b/code_puppy/plugins/prompt_newline/config.py new file mode 100644 index 000000000..f5198f736 --- /dev/null +++ b/code_puppy/plugins/prompt_newline/config.py @@ -0,0 +1,21 @@ +"""Plugin-local config for the prompt_newline plugin.""" + +from __future__ import annotations + +from code_puppy.config import get_value, set_config_value + +_CONFIG_KEY = "prompt_newline" +_TRUTHY = ("true", "1", "yes", "on") + + +def is_enabled() -> bool: + """Return True if the prompt_newline hack is enabled. Default: False.""" + cfg_val = get_value(_CONFIG_KEY) + if cfg_val is None: + return False + return str(cfg_val).strip().lower() in _TRUTHY + + +def set_enabled(enabled: bool) -> None: + """Persist the on/off switch to puppy.cfg.""" + set_config_value(_CONFIG_KEY, "true" if enabled else "false") diff --git a/code_puppy/plugins/prompt_newline/register_callbacks.py b/code_puppy/plugins/prompt_newline/register_callbacks.py new file mode 100644 index 000000000..22c4e4365 --- /dev/null +++ b/code_puppy/plugins/prompt_newline/register_callbacks.py @@ -0,0 +1,163 @@ +"""Register callbacks for the prompt_newline plugin. + +This plugin is a tiny ergonomics hack: it places the user's input cursor on a +*new line* below the puppy/agent/model/cwd chrome, so long working-directory +paths don't squeeze the typing area. + +It hooks two things: + +* ``startup`` — wraps ``get_prompt_with_active_model`` so the FormattedText it + returns gets a trailing ``\\n`` appended **at call time** (so the slash + command toggle takes effect immediately, no restart needed). +* ``custom_command`` / ``custom_command_help`` — exposes ``/prompt_newline`` + for runtime on/off, persisted via ``puppy.cfg``. + +Default: OFF. Opt-in only. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +from code_puppy.callbacks import register_callback +from code_puppy.plugins.prompt_newline.config import is_enabled, set_enabled + +_COMMAND_NAME = "prompt_newline" +_PATCH_ATTR = "_prompt_newline_original" + + +def _emit_info(message: str) -> None: + from code_puppy.messaging import emit_info + + emit_info(message) + + +def _emit_error(message: str) -> None: + from code_puppy.messaging import emit_error + + emit_error(message) + + +def _emit_success(message: str) -> None: + from code_puppy.messaging import emit_success + + emit_success(message) + + +def _append_newline(formatted_text): + """Return a new FormattedText with a trailing newline tuple. + + ``FormattedText`` is a ``list`` subclass of ``(style, text)`` tuples, so we + rebuild it rather than mutating in place — the upstream caller may cache + or reuse the returned object. + """ + from prompt_toolkit.formatted_text import FormattedText + + try: + return FormattedText(list(formatted_text) + [("", "\n")]) + except Exception: + # Defensive: never break the prompt if the upstream shape changes. + return formatted_text + + +def _install_prompt_patch() -> None: + """Monkey-patch ``get_prompt_with_active_model`` to honor ``is_enabled()``. + + Idempotent: re-running won't double-wrap. + """ + from code_puppy.command_line import prompt_toolkit_completion as ptc + + if getattr(ptc, _PATCH_ATTR, None) is not None: + return # Already patched + + original = ptc.get_prompt_with_active_model + setattr(ptc, _PATCH_ATTR, original) + + def patched(base: str = ">>> "): + result = original(base) + if is_enabled(): + return _append_newline(result) + return result + + ptc.get_prompt_with_active_model = patched + + +def _on_startup() -> None: + try: + _install_prompt_patch() + except Exception as exc: + # Plugins must fail gracefully — never crash the app. + _emit_error(f"prompt_newline: failed to install prompt patch — {exc}") + + +def _custom_help() -> List[Tuple[str, str]]: + return [ + ( + _COMMAND_NAME, + "Toggle placing user input on a new line below the prompt chrome", + ) + ] + + +def _parse_toggle_arg(command: str) -> Optional[bool]: + """Parse ``/prompt_newline [on|off|true|false|toggle]``. + + Returns: + True/False for an explicit set, or ``None`` to mean "flip current". + """ + tokens = command.strip().split() + if len(tokens) < 2: + return None # bare /prompt_newline → flip + arg = tokens[1].lower() + if arg in ("on", "true", "1", "yes", "enable", "enabled"): + return True + if arg in ("off", "false", "0", "no", "disable", "disabled"): + return False + if arg in ("toggle",): + return None + raise ValueError(arg) + + +def _handle_prompt_newline_command(command: str) -> bool: + try: + target = _parse_toggle_arg(command) + except ValueError as exc: + _emit_error( + f"/{_COMMAND_NAME}: unknown argument '{exc.args[0]}'. " + "Usage: /prompt_newline [on|off|toggle]" + ) + return True + + if target is None: + target = not is_enabled() + + set_enabled(target) + state = "ON" if target else "OFF" + _emit_success(f"🐶 prompt_newline is now {state}") + if target: + _emit_info("Your input will appear on a fresh line below the prompt chrome.") + else: + _emit_info("Prompt is back to single-line mode.") + return True + + +def _handle_custom_command(command: str, name: str): + if name != _COMMAND_NAME: + return None + return _handle_prompt_newline_command(command) + + +register_callback("startup", _on_startup) +register_callback("custom_command", _handle_custom_command) +register_callback("custom_command_help", _custom_help) + + +__all__ = [ + "_append_newline", + "_custom_help", + "_handle_custom_command", + "_handle_prompt_newline_command", + "_install_prompt_patch", + "_on_startup", + "_parse_toggle_arg", +] diff --git a/tests/plugins/test_prompt_newline_plugin.py b/tests/plugins/test_prompt_newline_plugin.py new file mode 100644 index 000000000..962bfd679 --- /dev/null +++ b/tests/plugins/test_prompt_newline_plugin.py @@ -0,0 +1,181 @@ +"""Tests for the prompt_newline plugin.""" + +from __future__ import annotations + +import importlib +import sys +from unittest.mock import MagicMock, patch + +import pytest + + +def _plugin_module(): + sys.modules.setdefault("dbos", MagicMock()) + return importlib.import_module( + "code_puppy.plugins.prompt_newline.register_callbacks" + ) + + +def _config_module(): + return importlib.import_module("code_puppy.plugins.prompt_newline.config") + + +def test_custom_help_lists_command(): + entries = dict(_plugin_module()._custom_help()) + assert "prompt_newline" in entries + + +def test_handle_custom_command_ignores_unrelated_names(): + assert _plugin_module()._handle_custom_command("/nope", "nope") is None + + +def test_parse_toggle_arg_defaults_to_none(): + assert _plugin_module()._parse_toggle_arg("/prompt_newline") is None + + +@pytest.mark.parametrize("arg", ["on", "true", "1", "yes", "ENABLE"]) +def test_parse_toggle_arg_truthy(arg): + assert _plugin_module()._parse_toggle_arg(f"/prompt_newline {arg}") is True + + +@pytest.mark.parametrize("arg", ["off", "false", "0", "no", "Disable"]) +def test_parse_toggle_arg_falsy(arg): + assert _plugin_module()._parse_toggle_arg(f"/prompt_newline {arg}") is False + + +def test_parse_toggle_arg_toggle_keyword_returns_none(): + assert _plugin_module()._parse_toggle_arg("/prompt_newline toggle") is None + + +def test_parse_toggle_arg_rejects_garbage(): + with pytest.raises(ValueError): + _plugin_module()._parse_toggle_arg("/prompt_newline banana") + + +def test_append_newline_returns_formatted_text_with_trailing_newline(): + from prompt_toolkit.formatted_text import FormattedText + + original = FormattedText([("class:arrow", ">>> ")]) + result = _plugin_module()._append_newline(original) + + assert isinstance(result, FormattedText) + assert list(result)[-1] == ("", "\n") + # original must not be mutated + assert ("", "\n") not in list(original) + + +def test_install_prompt_patch_is_idempotent(): + module = _plugin_module() + from code_puppy.command_line import prompt_toolkit_completion as ptc + + original = ptc.get_prompt_with_active_model + try: + module._install_prompt_patch() + first_patched = ptc.get_prompt_with_active_model + module._install_prompt_patch() + second_patched = ptc.get_prompt_with_active_model + assert first_patched is second_patched + # Original is preserved on the module for restoration + assert getattr(ptc, "_prompt_newline_original") is original + finally: + ptc.get_prompt_with_active_model = original + if hasattr(ptc, "_prompt_newline_original"): + delattr(ptc, "_prompt_newline_original") + + +def test_patched_prompt_appends_newline_only_when_enabled(): + module = _plugin_module() + from code_puppy.command_line import prompt_toolkit_completion as ptc + + original = ptc.get_prompt_with_active_model + try: + module._install_prompt_patch() + + with patch( + "code_puppy.plugins.prompt_newline.register_callbacks.is_enabled", + return_value=False, + ): + disabled_result = ptc.get_prompt_with_active_model() + + with patch( + "code_puppy.plugins.prompt_newline.register_callbacks.is_enabled", + return_value=True, + ): + enabled_result = ptc.get_prompt_with_active_model() + + assert ("", "\n") not in list(disabled_result) + assert list(enabled_result)[-1] == ("", "\n") + finally: + ptc.get_prompt_with_active_model = original + if hasattr(ptc, "_prompt_newline_original"): + delattr(ptc, "_prompt_newline_original") + + +def test_handle_command_persists_explicit_on(tmp_path, monkeypatch): + # Point the config at a throwaway file so we don't trash real puppy.cfg + cfg_file = tmp_path / "puppy.cfg" + monkeypatch.setattr("code_puppy.config.CONFIG_FILE", str(cfg_file)) + + module = _plugin_module() + cfg = _config_module() + + with ( + patch( + "code_puppy.plugins.prompt_newline.register_callbacks._emit_success" + ) as mock_success, + patch("code_puppy.plugins.prompt_newline.register_callbacks._emit_info"), + ): + result = module._handle_custom_command("/prompt_newline on", "prompt_newline") + + assert result is True + assert cfg.is_enabled() is True + assert "ON" in str(mock_success.call_args) + + +def test_handle_command_flips_when_no_arg(tmp_path, monkeypatch): + cfg_file = tmp_path / "puppy.cfg" + monkeypatch.setattr("code_puppy.config.CONFIG_FILE", str(cfg_file)) + + module = _plugin_module() + cfg = _config_module() + + cfg.set_enabled(False) + with ( + patch("code_puppy.plugins.prompt_newline.register_callbacks._emit_success"), + patch("code_puppy.plugins.prompt_newline.register_callbacks._emit_info"), + ): + module._handle_custom_command("/prompt_newline", "prompt_newline") + assert cfg.is_enabled() is True + + with ( + patch("code_puppy.plugins.prompt_newline.register_callbacks._emit_success"), + patch("code_puppy.plugins.prompt_newline.register_callbacks._emit_info"), + ): + module._handle_custom_command("/prompt_newline", "prompt_newline") + assert cfg.is_enabled() is False + + +def test_handle_command_rejects_garbage_arg(tmp_path, monkeypatch): + cfg_file = tmp_path / "puppy.cfg" + monkeypatch.setattr("code_puppy.config.CONFIG_FILE", str(cfg_file)) + + module = _plugin_module() + + with patch( + "code_puppy.plugins.prompt_newline.register_callbacks._emit_error" + ) as mock_error: + result = module._handle_custom_command( + "/prompt_newline banana", "prompt_newline" + ) + + assert result is True + mock_error.assert_called_once() + assert "banana" in str(mock_error.call_args) + + +def test_is_enabled_defaults_to_false(tmp_path, monkeypatch): + cfg_file = tmp_path / "puppy.cfg" + monkeypatch.setattr("code_puppy.config.CONFIG_FILE", str(cfg_file)) + + cfg = _config_module() + assert cfg.is_enabled() is False