Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"platformdirs>=3.0.0",
"textual>=0.86.0",
"psutil>=5.9.0",
"prompt_toolkit>=3.0.0",
]

[project.urls]
Expand Down
21 changes: 21 additions & 0 deletions tests/test_cli_argv.py
Original file line number Diff line number Diff line change
@@ -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"]
23 changes: 23 additions & 0 deletions tests/test_help_docs.py
Original file line number Diff line number Diff line change
@@ -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
58 changes: 39 additions & 19 deletions trushell/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"

Expand Down
30 changes: 25 additions & 5 deletions trushell/commands/core.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -20,7 +40,7 @@ def run_help(_: str) -> None:
print()
if len(cmds) % cols != 0:
print()
print("\nType 'help <command>' for more info (coming soon).")
print("\nType 'help <command>' for more info.")


def run_exit(_: str) -> str:
Expand Down
68 changes: 46 additions & 22 deletions trushell/commands/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
Loading