diff --git a/pyproject.toml b/pyproject.toml index aed8569..5ed31ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "platformdirs>=3.0.0", "textual>=0.86.0", "psutil>=5.9.0", + "prompt_toolkit>=3.0.0", ] [project.urls] diff --git a/tests/test_cli_argv.py b/tests/test_cli_argv.py new file mode 100644 index 0000000..64653b3 --- /dev/null +++ b/tests/test_cli_argv.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from trushell import cli + + +def test_app_with_lower_does_not_mutate_original_argv(monkeypatch): + original = ["trushell", "HeLp"] + monkeypatch.setattr(cli.sys, "argv", original) + + calls: list[str] = [] + + class FakeKernel: + def execute_command(self, raw: str) -> None: + calls.append(raw) + + monkeypatch.setattr(cli, "get_kernel", lambda: FakeKernel()) + + cli.app_with_lower() + + assert cli.sys.argv == original + assert calls == ["help"] diff --git a/tests/test_help_docs.py b/tests/test_help_docs.py new file mode 100644 index 0000000..8eb08dd --- /dev/null +++ b/tests/test_help_docs.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from trushell.commands.core import run_help + + +def test_run_help_prints_docstring_for_known_command(monkeypatch, capsys): + fake_kernel = SimpleNamespace( + registry={ + "settings": { + "path": "trushell/commands/settings.py", + "function": "run_settings", + } + } + ) + + monkeypatch.setattr("trushell.core.trukernel.get_kernel", lambda: fake_kernel) + + run_help("settings") + + out = capsys.readouterr().out + assert "Launch the TruShell settings TUI." in out diff --git a/trushell/cli.py b/trushell/cli.py index 79beb6a..80604c2 100644 --- a/trushell/cli.py +++ b/trushell/cli.py @@ -25,12 +25,16 @@ def app_with_lower() -> None: """Entry point that normalizes the first argument to lowercase for case-insensitive invocation.""" - if len(sys.argv) > 1: - sys.argv[1] = sys.argv[1].lower() - if sys.argv[1] not in {"--help", "-h", "version"}: - raw = " ".join(sys.argv[1:]) + argv = sys.argv.copy() + if len(argv) > 1: + argv[1] = argv[1].lower() + if argv[1] not in {"--help", "-h", "version"}: + raw = " ".join(argv[1:]) get_kernel().execute_command(raw) return + + if argv != sys.argv: + sys.argv = argv app() @@ -44,7 +48,20 @@ def _split_command(user_input: str) -> tuple[str, str]: def _prompt_command() -> tuple[str, str, str]: - raw_command = input(f"trushell {os.getcwd()} ❯ ").strip() + try: + try: + from prompt_toolkit import prompt as prompt_toolkit_prompt + except ImportError: + prompt_toolkit_prompt = None + + if prompt_toolkit_prompt is not None: + raw_command = prompt_toolkit_prompt(f"trushell {os.getcwd()} ❯ ") + else: + raw_command = input("trushell> ") + except UnicodeEncodeError: + raw_command = input("trushell> ") + + raw_command = raw_command.strip() command, argument = _split_command(raw_command) return raw_command, command, argument @@ -117,20 +134,23 @@ def _run_external_command( peak_cpu = 0.0 start = time.perf_counter() - while True: + try: + while True: + try: + process.wait(timeout=0.05) + break + except subprocess.TimeoutExpired: + if monitor is not None: + try: + peak_rss = max(peak_rss, monitor.memory_info().rss) + peak_cpu = max(peak_cpu, monitor.cpu_percent(None)) + except (Exception, OSError): + break + finally: try: - process.wait(timeout=0.05) - break - except subprocess.TimeoutExpired: - if monitor is not None: - try: - peak_rss = max(peak_rss, monitor.memory_info().rss) - peak_cpu = max(peak_cpu, monitor.cpu_percent(None)) - except (Exception, OSError): - break - - if process.returncode is None: - process.wait() + process.wait() + except Exception: + pass if monitor is not None: try: @@ -242,7 +262,7 @@ def _handle_local_command(command: str, argument: str) -> str: launch_settings() return "handled" if command == "help": - typer.echo("Available commands: joke, joke_trex, addtask, deletetask, updatetask, completetask, showtask, now, time, world, tz, alarm, sw, settings, exit, help") + typer.echo("Available commands: joke, joke_trex, addtask, deletetask, updatetask, completetask, showtasks, now, time, world, tz, alarm, sw, settings, exit, help") return "handled" return "unhandled" diff --git a/trushell/commands/core.py b/trushell/commands/core.py index 42807f1..8fb8d49 100644 --- a/trushell/commands/core.py +++ b/trushell/commands/core.py @@ -1,17 +1,37 @@ from __future__ import annotations +import importlib +import inspect import os import subprocess -def run_help(_: str) -> None: - """Display available commands by reading the manifest registry.""" - # Import here to avoid circular dependency if trukernel imports core +def run_help(args: str) -> None: + """Display available commands or the docstring for a specific command.""" from trushell.core.trukernel import get_kernel + kernel = get_kernel() + command = args.strip().lower() if args else "" + + if command: + entry = kernel.registry.get(command) + if entry is not None: + try: + module_name = entry["path"].replace("/", ".").removesuffix(".py") + module = importlib.import_module(module_name) + func = getattr(module, entry["function"]) + doc = inspect.getdoc(func) + if doc: + print(doc) + return + except Exception: + pass + + print(f"No help available for '{command}'.") + return + cmds = sorted(kernel.registry.keys()) print("Available commands:") - # Print in columns col_width = max(len(c) for c in cmds) + 2 cols = 4 for i, cmd in enumerate(cmds): @@ -20,7 +40,7 @@ def run_help(_: str) -> None: print() if len(cmds) % cols != 0: print() - print("\nType 'help ' for more info (coming soon).") + print("\nType 'help ' for more info.") def run_exit(_: str) -> str: diff --git a/trushell/commands/settings.py b/trushell/commands/settings.py index c8736ec..6a9c37a 100644 --- a/trushell/commands/settings.py +++ b/trushell/commands/settings.py @@ -74,6 +74,7 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) self.manager = SettingsManager() self.settings = self.manager.load() + self.dirty_settings = dict(self.settings) self.selected_category = "General" def compose(self) -> ComposeResult: @@ -117,6 +118,27 @@ def action_save_settings(self) -> None: def action_quit_app(self) -> None: self.exit() + def _update_dirty_setting(self, key: str, value: object) -> None: + self.dirty_settings[key] = value + self.settings[key] = value + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "prompt_symbol": + self._update_dirty_setting("prompt_symbol", event.value) + elif event.input.id == "csv_max_rows": + self._update_dirty_setting("csv_max_rows", event.value) + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id == "theme_selector": + self._update_dirty_setting("theme", event.value) + self._apply_theme(str(event.value)) + + def on_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "show_git_status": + self._update_dirty_setting("show_git_status", bool(event.value)) + elif event.switch.id == "auto_complete": + self._update_dirty_setting("auto_complete", bool(event.value)) + def _refresh_form(self) -> None: form_container = self.query_one("#form_container", Vertical) @@ -136,56 +158,58 @@ def add_field(label_text: str, widget: Static | Input | Select | Switch) -> None if current_cat == "General": add_field( "Prompt symbol:", - Input(value=str(self.settings.get("prompt_symbol", "➜")), id="prompt_symbol"), + Input(value=str(self.dirty_settings.get("prompt_symbol", self.settings.get("prompt_symbol", "➜"))), id="prompt_symbol"), ) elif current_cat == "Appearance": add_field( "Theme:", Select( options=self.THEME_OPTIONS, - value=str(self.settings.get("theme", "dark")), + value=str(self.dirty_settings.get("theme", self.settings.get("theme", "dark"))), id="theme_selector", ), ) elif current_cat == "Navigation": add_field( "Show git status:", - Switch(value=bool(self.settings.get("show_git_status", True)), id="show_git_status"), + Switch(value=bool(self.dirty_settings.get("show_git_status", self.settings.get("show_git_status", True))), id="show_git_status"), ) add_field( "Auto complete:", - Switch(value=bool(self.settings.get("auto_complete", True)), id="auto_complete"), + Switch(value=bool(self.dirty_settings.get("auto_complete", self.settings.get("auto_complete", True))), id="auto_complete"), ) elif current_cat == "Data": add_field( "CSV max rows:", - Input(value=str(self.settings.get("csv_max_rows", 50)), id="csv_max_rows"), + Input(value=str(self.dirty_settings.get("csv_max_rows", self.settings.get("csv_max_rows", 50))), id="csv_max_rows"), ) def _save_settings(self) -> None: - theme = self.query_one("#theme_selector", Select).value if self.selected_category == "Appearance" else self.settings.get("theme", "dark") - prompt_symbol = self.query_one("#prompt_symbol", Input).value if self.selected_category == "General" else self.settings.get("prompt_symbol", "➜") - show_git_status = self.query_one("#show_git_status", Switch).value if self.selected_category == "Navigation" else self.settings.get("show_git_status", True) - auto_complete = self.query_one("#auto_complete", Switch).value if self.selected_category == "Navigation" else self.settings.get("auto_complete", True) - csv_max_rows_value = self.query_one("#csv_max_rows", Input).value if self.selected_category == "Data" else str(self.settings.get("csv_max_rows", 50)) + theme = str(self.dirty_settings.get("theme", self.settings.get("theme", "dark"))) + prompt_symbol = str(self.dirty_settings.get("prompt_symbol", self.settings.get("prompt_symbol", "➜"))) + show_git_status = bool(self.dirty_settings.get("show_git_status", self.settings.get("show_git_status", True))) + auto_complete = bool(self.dirty_settings.get("auto_complete", self.settings.get("auto_complete", True))) + csv_max_rows_value = self.dirty_settings.get("csv_max_rows", self.settings.get("csv_max_rows", 50)) try: csv_max_rows = int(csv_max_rows_value) - except ValueError: + except (TypeError, ValueError): self.notify("CSV max rows must be an integer.", severity="error") return - self.settings["theme"] = theme - self.settings["prompt_symbol"] = prompt_symbol - self.settings["show_git_status"] = show_git_status - self.settings["auto_complete"] = auto_complete - self.settings["csv_max_rows"] = csv_max_rows - - self.manager.set("theme", theme) - self.manager.set("prompt_symbol", prompt_symbol) - self.manager.set("show_git_status", show_git_status) - self.manager.set("auto_complete", auto_complete) - self.manager.set("csv_max_rows", csv_max_rows) + self.dirty_settings.update( + { + "theme": theme, + "prompt_symbol": prompt_symbol, + "show_git_status": show_git_status, + "auto_complete": auto_complete, + "csv_max_rows": csv_max_rows, + } + ) + self.settings.update(self.dirty_settings) + + for key, value in self.dirty_settings.items(): + self.manager.set(key, value) self.manager.save() self._apply_theme(theme)