diff --git a/features/steps/tdd_slash_overlay_keyboard_nav_steps.py b/features/steps/tdd_slash_overlay_keyboard_nav_steps.py new file mode 100644 index 000000000..5ba86bc18 --- /dev/null +++ b/features/steps/tdd_slash_overlay_keyboard_nav_steps.py @@ -0,0 +1,113 @@ +"""Step definitions for tdd_slash_overlay_keyboard_nav.feature. + +These steps verify that SlashCommandOverlay supports keyboard navigation +per issue #10442: navigate_up, navigate_down, select_current, dismiss. +""" + +from __future__ import annotations + +from behave import given, then, when + +from cleveragents.tui.slash_catalog import SlashCommandSpec + +_TEST_COMMANDS: list[SlashCommandSpec] = [ + SlashCommandSpec(command="help", group="Utility", description="Show help"), + SlashCommandSpec(command="settings", group="Utility", description="Open settings"), + SlashCommandSpec(command="clear", group="Utility", description="Clear display"), +] + + +@given("the overlay has commands loaded") +def step_overlay_has_commands(context: object) -> None: + """Load test commands into the overlay.""" + context.overlay.set_commands("", _TEST_COMMANDS) + context.test_commands = _TEST_COMMANDS + + +@given("the overlay selected_index is set to {index:d}") +def step_set_selected_index(context: object, index: int) -> None: + """Set the overlay selected_index to a specific value.""" + context.overlay.selected_index = index + + +@when("I call navigate_down on the overlay") +def step_navigate_down(context: object) -> None: + """Call navigate_down on the overlay.""" + context.overlay.navigate_down() + + +@when("I call navigate_up on the overlay") +def step_navigate_up(context: object) -> None: + """Call navigate_up on the overlay.""" + context.overlay.navigate_up() + + +@when("I call navigate_down on the overlay many times") +def step_navigate_down_many(context: object) -> None: + """Call navigate_down many times to test boundary.""" + for _ in range(20): + context.overlay.navigate_down() + + +@when("I call select_current on the overlay") +def step_select_current(context: object) -> None: + """Call select_current on the overlay and store result.""" + context.selected_command = context.overlay.select_current() + + +@then("the overlay should have a navigate_up method") +def step_has_navigate_up(context: object) -> None: + """Verify navigate_up method exists.""" + assert hasattr(context.overlay, "navigate_up"), "Must have navigate_up" + assert callable(context.overlay.navigate_up), "navigate_up must be callable" + + +@then("the overlay should have a navigate_down method") +def step_has_navigate_down(context: object) -> None: + """Verify navigate_down method exists.""" + assert hasattr(context.overlay, "navigate_down"), "Must have navigate_down" + assert callable(context.overlay.navigate_down), "navigate_down must be callable" + + +@then("the overlay should have a select_current method") +def step_has_select_current(context: object) -> None: + """Verify select_current method exists.""" + assert hasattr(context.overlay, "select_current"), "Must have select_current" + assert callable(context.overlay.select_current), "select_current must be callable" + + +@then("the overlay should have a dismiss method") +def step_has_dismiss(context: object) -> None: + """Verify dismiss method exists.""" + assert hasattr(context.overlay, "dismiss"), "Must have dismiss" + assert callable(context.overlay.dismiss), "dismiss must be callable" + + +@then("the overlay should have a selected_index attribute") +def step_has_selected_index(context: object) -> None: + """Verify selected_index attribute exists.""" + assert hasattr(context.overlay, "selected_index"), "Must have selected_index" + + +@then("the overlay selected_index should be {expected:d}") +def step_selected_index_equals(context: object, expected: int) -> None: + """Verify the overlay selected_index equals the expected value.""" + actual = context.overlay.selected_index + assert actual == expected, f"Expected selected_index={expected}, got {actual}" + + +@then("the overlay selected_index should not exceed the command count") +def step_selected_index_bounded(context: object) -> None: + """Verify selected_index does not exceed the number of commands.""" + count = len(context.test_commands) + actual = context.overlay.selected_index + assert actual < count, f"selected_index={actual} must be < command count={count}" + + +@then("the selected command should be the second command in the list") +def step_selected_is_second(context: object) -> None: + """Verify select_current returned the second command.""" + expected = context.test_commands[1] + assert context.selected_command == expected, ( + f"Expected {expected!r}, got {context.selected_command!r}" + ) diff --git a/features/tdd_slash_overlay_keyboard_nav.feature b/features/tdd_slash_overlay_keyboard_nav.feature new file mode 100644 index 000000000..a74e1cc91 --- /dev/null +++ b/features/tdd_slash_overlay_keyboard_nav.feature @@ -0,0 +1,60 @@ +@tdd_issue @tdd_issue_10442 +Feature: TDD Issue #10442 — SlashCommandOverlay keyboard navigation + As a developer + I want to verify that SlashCommandOverlay supports keyboard navigation + So that users can navigate the slash command list with up/down/Enter/Escape + + Background: + Given the slash command overlay module is imported + + Scenario: SlashCommandOverlay has navigate_up method + Given I have a SlashCommandOverlay instance + Then the overlay should have a navigate_up method + + Scenario: SlashCommandOverlay has navigate_down method + Given I have a SlashCommandOverlay instance + Then the overlay should have a navigate_down method + + Scenario: SlashCommandOverlay has select_current method + Given I have a SlashCommandOverlay instance + Then the overlay should have a select_current method + + Scenario: SlashCommandOverlay has dismiss method + Given I have a SlashCommandOverlay instance + Then the overlay should have a dismiss method + + Scenario: SlashCommandOverlay has selected_index attribute + Given I have a SlashCommandOverlay instance + Then the overlay should have a selected_index attribute + + Scenario: navigate_down increments selected_index + Given I have a SlashCommandOverlay instance + And the overlay has commands loaded + When I call navigate_down on the overlay + Then the overlay selected_index should be 1 + + Scenario: navigate_up decrements selected_index + Given I have a SlashCommandOverlay instance + And the overlay has commands loaded + And the overlay selected_index is set to 2 + When I call navigate_up on the overlay + Then the overlay selected_index should be 1 + + Scenario: navigate_up does not go below zero + Given I have a SlashCommandOverlay instance + And the overlay has commands loaded + When I call navigate_up on the overlay + Then the overlay selected_index should be 0 + + Scenario: navigate_down does not exceed command count + Given I have a SlashCommandOverlay instance + And the overlay has commands loaded + When I call navigate_down on the overlay many times + Then the overlay selected_index should not exceed the command count + + Scenario: select_current returns the currently selected command + Given I have a SlashCommandOverlay instance + And the overlay has commands loaded + And the overlay selected_index is set to 1 + When I call select_current on the overlay + Then the selected command should be the second command in the list diff --git a/src/cleveragents/tui/widgets/slash_command_overlay.py b/src/cleveragents/tui/widgets/slash_command_overlay.py index 6726e5bb9..33ee67e6d 100644 --- a/src/cleveragents/tui/widgets/slash_command_overlay.py +++ b/src/cleveragents/tui/widgets/slash_command_overlay.py @@ -30,7 +30,12 @@ _COMMAND_COL_WIDTH = 28 # characters reserved for the command name column class SlashCommandOverlay(_StaticBase): - """Renderable list of slash command candidates.""" + """Renderable list of slash command candidates with keyboard navigation.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self.selected_index: int = 0 + self._commands: list[SlashCommandSpec] = [] def set_commands(self, query: str, commands: list[SlashCommandSpec]) -> None: filtered = ( @@ -38,6 +43,8 @@ class SlashCommandOverlay(_StaticBase): if query else list(commands) ) + self._commands = filtered + self.selected_index = 0 lines = [f"/{query}"] for spec in filtered[:12]: name_col = f" /{spec.command}" @@ -46,3 +53,26 @@ class SlashCommandOverlay(_StaticBase): if len(lines) == 1: lines.append(" (no commands)") self.update("\n".join(lines)) + + def navigate_up(self) -> None: + """Move selection up by one, clamped to zero.""" + if self.selected_index > 0: + self.selected_index -= 1 + + def navigate_down(self) -> None: + """Move selection down by one, clamped to last command.""" + max_index = len(self._commands) - 1 + if self.selected_index < max_index: + self.selected_index += 1 + + def select_current(self) -> SlashCommandSpec | None: + """Return the currently selected SlashCommandSpec, or None if empty.""" + if not self._commands: + return None + return self._commands[self.selected_index] + + def dismiss(self) -> None: + """Dismiss/hide the overlay (Escape key action).""" + self.update("") + self._commands = [] + self.selected_index = 0