From ff786212bc6032717fe30227eb4af695b611282b Mon Sep 17 00:00:00 2001 From: Aura - jc <67582323+Catafal@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:38:05 +0200 Subject: [PATCH] feat: REPL commands work without the leading slash `sessions bach` == `/sessions bach`. Lookup retries with a '/' prefix on miss, so '/' stays canonical and the handler table has no duplicate keys. Tab-completion offers both spellings. Co-Authored-By: Claude Fable 5 --- src/bach/repl/app.py | 18 +++++++++++++++--- tests/unit/test_repl_app.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/bach/repl/app.py b/src/bach/repl/app.py index 71cae37..c6d2058 100644 --- a/src/bach/repl/app.py +++ b/src/bach/repl/app.py @@ -200,7 +200,9 @@ def run(self) -> None: # correctly suggests `/project-add` / `/project-init` / `/projects`. # Returns None when prompt_toolkit isn't installed — completer # kwarg is then silently ignored downstream. - main_completer = _build_word_completer(sorted(_HANDLERS.keys()), sentence=True) + # Offer both spellings in tab-completion: canonical /cmd and bare cmd. + completion_words = sorted({*_HANDLERS.keys(), *(k.lstrip("/") for k in _HANDLERS)}) + main_completer = _build_word_completer(completion_words, sentence=True) while not self._should_quit: _render_dashboard(self) @@ -469,14 +471,24 @@ def _format_title_with_hierarchy(task: DailyTask, theme: ThemePalette) -> str: def _dispatch(app: BachRepl, line: str) -> None: - """Parse, resolve aliases, run the handler.""" + """Parse, resolve aliases, run the handler. + + Commands work with or without the leading slash: `sessions bach` is + `/sessions bach`. The slashless form is resolved by retrying the + lookup with a '/' prefix, so '/' stays the canonical spelling and the + handler table never needs duplicate keys. + """ name, *args = line.split(maxsplit=1) arg_str = args[0] if args else "" canonical = _ALIASES.get(name, name) handler = _HANDLERS.get(canonical) + if handler is None and not name.startswith("/"): + slashed = f"/{name}" + canonical = _ALIASES.get(slashed, slashed) + handler = _HANDLERS.get(canonical) if handler is None: t = app.current_theme - app.console.print(f"[{t.error}]Unknown:[/{t.error}] {name} (try /help)") + app.console.print(f"[{t.error}]Unknown:[/{t.error}] {name} (try help)") return logger.info("event=repl_cmd_start cmd=%s", canonical) try: diff --git a/tests/unit/test_repl_app.py b/tests/unit/test_repl_app.py index 87d6106..5e4b49a 100644 --- a/tests/unit/test_repl_app.py +++ b/tests/unit/test_repl_app.py @@ -1455,3 +1455,38 @@ def test_clear_alias_c_routes_to_clear( app.run() captured = capsys.readouterr() assert "\x1b[H\x1b[2J\x1b[3J" in captured.out + + +# --------------------------------------------------------------------------- +# Slashless commands — `sessions` works like `/sessions` +# --------------------------------------------------------------------------- + + +def test_repl_command_works_without_slash(tmp_path: Path) -> None: + """Bare `quit` resolves through the same handler table as `/quit`.""" + registry = _registry_with_project(tmp_path) + app = BachRepl( + registry, + input_fn=_scripted_input(["quit"]), + ) + app.run() # would hang (or exhaust input) if `quit` weren't dispatched + + +def test_repl_bare_command_with_args(tmp_path: Path) -> None: + """Args pass through the slashless path: `help board` == `/help board`.""" + registry = _registry_with_project(tmp_path) + app = BachRepl( + registry, + input_fn=_scripted_input(["help board", "quit"]), + ) + app.run() # /help handler runs with 'board'; loop survives to quit + + +def test_repl_bare_unknown_command_still_errors(tmp_path: Path) -> None: + """A bare token that matches no command reports Unknown and survives.""" + registry = _registry_with_project(tmp_path) + app = BachRepl( + registry, + input_fn=_scripted_input(["definitelynotacommand", "quit"]), + ) + app.run()