diff --git a/features/steps/tui_slash_command_overlay_coverage_steps.py b/features/steps/tui_slash_command_overlay_coverage_steps.py index f5a477d2f..61ed8fe58 100644 --- a/features/steps/tui_slash_command_overlay_coverage_steps.py +++ b/features/steps/tui_slash_command_overlay_coverage_steps.py @@ -5,7 +5,7 @@ These steps target uncovered lines in slash_command_overlay.py: - Line 19: _FallbackStatic.update sets self._text = text - Lines 31-32: set_commands filtering logic (query vs empty query) - Line 34: lines = [f"/{query}"] -- Lines 35-36: loop appending filtered commands +- Lines 35-36: loop appending filtered commands with descriptions - Lines 37-38: "(no commands)" fallback when nothing matches - Line 39: self.update("\n".join(lines)) """ @@ -16,9 +16,21 @@ from unittest.mock import patch from behave import given, then, when import cleveragents.tui.widgets.slash_command_overlay as _sco_mod +from cleveragents.tui.slash_catalog import SlashCommandSpec from cleveragents.tui.widgets.slash_command_overlay import _load_static_base +def _make_specs(names_csv: str) -> list[SlashCommandSpec]: + """Build a list of SlashCommandSpec from a CSV of command names.""" + return [ + SlashCommandSpec( + command=c.strip(), group="Test", description=f"Desc for {c.strip()}" + ) + for c in names_csv.split(",") + if c.strip() + ] + + # --------------------------------------------------------------------------- # Background — force FallbackStatic so tests never need a live Textual app # --------------------------------------------------------------------------- @@ -81,16 +93,16 @@ def step_verify_text(context, expected): # --------------------------------------------------------------------------- @when('I call set_commands with query "{query}" and commands "{commands_csv}"') def step_call_set_commands(context, query, commands_csv): - """Call set_commands with a query and a CSV list of commands.""" - commands = [c.strip() for c in commands_csv.split(",") if c.strip()] - context.overlay.set_commands(query, commands) + """Call set_commands with a query and a CSV list of commands (as SlashCommandSpec).""" + specs = _make_specs(commands_csv) + context.overlay.set_commands(query, specs) @when('I call set_commands with empty query and commands "{commands_csv}"') def step_call_set_commands_empty_query(context, commands_csv): - """Call set_commands with an empty query and a CSV list of commands.""" - commands = [c.strip() for c in commands_csv.split(",") if c.strip()] - context.overlay.set_commands("", commands) + """Call set_commands with an empty query and a CSV list of commands (as SlashCommandSpec).""" + specs = _make_specs(commands_csv) + context.overlay.set_commands("", specs) @then('the overlay text should contain "{substring}"') @@ -128,9 +140,12 @@ def step_text_not_contains(context, substring): 'I call set_commands with query "" and {count:d} commands prefixed with "{prefix}"' ) def step_call_set_commands_many(context, count, prefix): - """Call set_commands with a large number of commands.""" - commands = [f"{prefix}{i}" for i in range(count)] - context.overlay.set_commands("", commands) + """Call set_commands with a large number of commands (as SlashCommandSpec).""" + specs = [ + SlashCommandSpec(command=f"{prefix}{i}", group="Test", description=f"Desc {i}") + for i in range(count) + ] + context.overlay.set_commands("", specs) @then("the overlay text should have exactly {expected:d} lines") diff --git a/features/steps/tui_slash_overlay_descriptions_steps.py b/features/steps/tui_slash_overlay_descriptions_steps.py new file mode 100644 index 000000000..a6b076445 --- /dev/null +++ b/features/steps/tui_slash_overlay_descriptions_steps.py @@ -0,0 +1,29 @@ +"""Step definitions for tui_slash_overlay_descriptions.feature. + +These steps verify that SlashCommandOverlay.set_commands() renders +command descriptions alongside command names per ADR-046. +""" + +from __future__ import annotations + +from behave import then, when + +from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS + + +@when("I initialise the overlay with real slash command specs") +def step_init_with_real_specs(context): + """Call set_commands with the real SLASH_COMMAND_SPECS from the catalog.""" + context.overlay.set_commands("", list(SLASH_COMMAND_SPECS)) + + +@then("the overlay text should contain description for {command!r}") +def step_overlay_contains_description(context, command): + """Verify the overlay text contains the description for the given command.""" + from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS + + spec = next((s for s in SLASH_COMMAND_SPECS if s.command == command), None) + assert spec is not None, f"Command {command!r} not found in SLASH_COMMAND_SPECS" + assert spec.description in context.overlay._text, ( + f"Expected description '{spec.description}' in:\n{context.overlay._text}" + ) diff --git a/features/tui_slash_command_overlay_coverage.feature b/features/tui_slash_command_overlay_coverage.feature index 0f6454987..7ac8b252c 100644 --- a/features/tui_slash_command_overlay_coverage.feature +++ b/features/tui_slash_command_overlay_coverage.feature @@ -47,3 +47,11 @@ Feature: TUI Slash Command Overlay Coverage When I force the fallback static base to load Then the fallback class should be usable as a standalone object And calling update on the fallback instance should store the text + + Scenario: set_commands renders descriptions alongside command names + Given I have a SlashCommandOverlay instance + When I call set_commands with query "" and commands "session:create,session:list" + Then the overlay text should contain " /session:create" + And the overlay text should contain "Desc for session:create" + And the overlay text should contain " /session:list" + And the overlay text should contain "Desc for session:list" diff --git a/features/tui_slash_overlay_descriptions.feature b/features/tui_slash_overlay_descriptions.feature new file mode 100644 index 000000000..a538582b5 --- /dev/null +++ b/features/tui_slash_overlay_descriptions.feature @@ -0,0 +1,37 @@ +Feature: TUI Slash Command Overlay Shows Descriptions + Per ADR-046, the SlashCommandOverlay must display each command with its + description alongside the command name in aligned columns. + + Background: + Given the slash command overlay module is imported + + Scenario: Overlay renders command name and description in aligned columns + Given I have a SlashCommandOverlay instance + When I call set_commands with query "" and commands "session:create,settings" + Then the overlay text should contain " /session:create" + And the overlay text should contain "Desc for session:create" + And the overlay text should contain " /settings" + And the overlay text should contain "Desc for settings" + + Scenario: Overlay renders descriptions for filtered commands + Given I have a SlashCommandOverlay instance + When I call set_commands with query "session" and commands "session:create,session:list,plan:use" + Then the overlay text should contain " /session:create" + And the overlay text should contain "Desc for session:create" + And the overlay text should contain " /session:list" + And the overlay text should contain "Desc for session:list" + And the overlay text should not contain " /plan:use" + + Scenario: Overlay uses real slash_command_specs from catalog + Given I have a SlashCommandOverlay instance + When I initialise the overlay with real slash command specs + Then the overlay text should contain " /session:create" + And the overlay text should contain "Create a new session tab" + And the overlay text should contain " /settings" + And the overlay text should contain "Open settings" + + @tdd_expected_fail + Scenario: TDD capture - overlay previously showed names only without descriptions + Given I have a SlashCommandOverlay instance + When I initialise the overlay with real slash command specs + Then the overlay text should not contain "Create a new session tab" diff --git a/robot/tui_smoke.robot b/robot/tui_smoke.robot index c2ed219f9..f46147c92 100644 --- a/robot/tui_smoke.robot +++ b/robot/tui_smoke.robot @@ -41,6 +41,30 @@ TUI Headless Includes Router Help Payload Should Contain ${result.stdout} "help" Should Contain ${result.stdout} /persona +Slash Command Overlay Renders Descriptions + [Documentation] Verify SlashCommandOverlay.set_commands renders descriptions alongside names + ${script}= Catenate SEPARATOR=\n + ... import importlib + ... from unittest.mock import patch + ... import cleveragents.tui.widgets.slash_command_overlay as _sco_mod + ... from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS, slash_command_specs + ... with patch("importlib.import_module", side_effect=ImportError("no textual")): + ... importlib.reload(_sco_mod) + ... overlay = _sco_mod.SlashCommandOverlay() + ... overlay.set_commands("", slash_command_specs()) + ... text = overlay._text + ... assert " /session:create" in text, f"Missing /session:create in: {text}" + ... assert "Create a new session tab" in text, f"Missing description in: {text}" + ... overlay2 = _sco_mod.SlashCommandOverlay() + ... overlay2.set_commands("settings", slash_command_specs()) + ... text2 = overlay2._text + ... assert " /settings" in text2, f"Missing /settings in: {text2}" + ... assert "Open settings" in text2, f"Missing settings description in: {text2}" + ... print("slash-overlay-descriptions-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False stderr=STDOUT + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} slash-overlay-descriptions-ok + TUI Help Panel Context Switching ${script}= Catenate SEPARATOR=\n ... from types import SimpleNamespace diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index df18a911e..4a8c66dcf 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -11,7 +11,7 @@ from cleveragents.tui.first_run import create_default_persona_for_actor, is_firs from cleveragents.tui.input.modes import InputMode, InputModeRouter from cleveragents.tui.input.reference_parser import suggestions from cleveragents.tui.persona.state import PersonaState -from cleveragents.tui.slash_catalog import slash_command_names +from cleveragents.tui.slash_catalog import slash_command_specs from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay from cleveragents.tui.widgets.help_panel_overlay import ( HelpPanelOverlay, @@ -130,7 +130,7 @@ if _TEXTUAL_AVAILABLE: ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) ref_picker.set_suggestions("", []) slash = self.query_one("#slash-overlay", SlashCommandOverlay) - slash.set_commands("", slash_command_names()) + slash.set_commands("", slash_command_specs()) actor_overlay = self.query_one("#actor-selection", ActorSelectionOverlay) if first_run: actor_overlay.show() diff --git a/src/cleveragents/tui/slash_catalog.py b/src/cleveragents/tui/slash_catalog.py index df65533a1..572acab86 100644 --- a/src/cleveragents/tui/slash_catalog.py +++ b/src/cleveragents/tui/slash_catalog.py @@ -93,6 +93,11 @@ def slash_command_names() -> list[str]: return [spec.command for spec in SLASH_COMMAND_SPECS] +def slash_command_specs() -> list[SlashCommandSpec]: + """Return all slash command specs for the overlay (includes descriptions).""" + return list(SLASH_COMMAND_SPECS) + + def slash_command_groups() -> set[str]: """Return unique slash command groups represented in the catalog.""" return {spec.group for spec in SLASH_COMMAND_SPECS} diff --git a/src/cleveragents/tui/widgets/slash_command_overlay.py b/src/cleveragents/tui/widgets/slash_command_overlay.py index 7b0a823eb..6726e5bb9 100644 --- a/src/cleveragents/tui/widgets/slash_command_overlay.py +++ b/src/cleveragents/tui/widgets/slash_command_overlay.py @@ -3,7 +3,10 @@ from __future__ import annotations import importlib -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from cleveragents.tui.slash_catalog import SlashCommandSpec def _load_static_base() -> type[Any]: @@ -23,17 +26,23 @@ def _load_static_base() -> type[Any]: _StaticBase = _load_static_base() +_COMMAND_COL_WIDTH = 28 # characters reserved for the command name column + class SlashCommandOverlay(_StaticBase): """Renderable list of slash command candidates.""" - def set_commands(self, query: str, commands: list[str]) -> None: + def set_commands(self, query: str, commands: list[SlashCommandSpec]) -> None: filtered = ( - [item for item in commands if item.startswith(query)] if query else commands + [item for item in commands if item.command.startswith(query)] + if query + else list(commands) ) lines = [f"/{query}"] - for command in filtered[:12]: - lines.append(f" /{command}") + for spec in filtered[:12]: + name_col = f" /{spec.command}" + padding = max(1, _COMMAND_COL_WIDTH - len(name_col)) + lines.append(f"{name_col}{' ' * padding}{spec.description}") if len(lines) == 1: lines.append(" (no commands)") self.update("\n".join(lines))