From a74c495a1ff24a07f06879c578972ec881a3f78f Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sun, 19 Apr 2026 09:41:18 +0000 Subject: [PATCH 1/3] test(tui): add BDD keyboard navigation for SlashCommandOverlay (#10442) Implements keyboard navigation support for SlashCommandOverlay: - navigate_up(): move selection up, clamped to index 0 - navigate_down(): move selection down, clamped to last command - select_current(): return the currently highlighted SlashCommandSpec - dismiss(): clear overlay and reset state (Escape key action) - selected_index: tracks current highlighted position Adds BDD feature file and step definitions covering all navigation scenarios including boundary conditions (clamp at 0 and max index). Closes #10442 --- .../tdd_slash_overlay_keyboard_nav_steps.py | 117 ++++++++++++++++++ .../tdd_slash_overlay_keyboard_nav.feature | 60 +++++++++ .../tui/widgets/slash_command_overlay.py | 32 ++++- 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 features/steps/tdd_slash_overlay_keyboard_nav_steps.py create mode 100644 features/tdd_slash_overlay_keyboard_nav.feature 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..721175f1b --- /dev/null +++ b/features/steps/tdd_slash_overlay_keyboard_nav_steps.py @@ -0,0 +1,117 @@ +"""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 -- 2.52.0 From deed7acbb2970fe233a8e82da05dfb125ddd259b Mon Sep 17 00:00:00 2001 From: CleverThis Date: Sun, 19 Apr 2026 12:04:54 +0000 Subject: [PATCH 2/3] style(tui): apply ruff format to tdd_slash_overlay_keyboard_nav_steps Remove unnecessary parentheses around f-string assert messages to satisfy ruff format --check in CI lint job. --- features/steps/tdd_slash_overlay_keyboard_nav_steps.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/features/steps/tdd_slash_overlay_keyboard_nav_steps.py b/features/steps/tdd_slash_overlay_keyboard_nav_steps.py index 721175f1b..5ba86bc18 100644 --- a/features/steps/tdd_slash_overlay_keyboard_nav_steps.py +++ b/features/steps/tdd_slash_overlay_keyboard_nav_steps.py @@ -93,9 +93,7 @@ def step_has_selected_index(context: object) -> None: 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}" - ) + assert actual == expected, f"Expected selected_index={expected}, got {actual}" @then("the overlay selected_index should not exceed the command count") @@ -103,9 +101,7 @@ 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}" - ) + assert actual < count, f"selected_index={actual} must be < command count={count}" @then("the selected command should be the second command in the list") -- 2.52.0 From f2fef34ab9aeb2c22efe7f617f8eb63f132ef77c Mon Sep 17 00:00:00 2001 From: CleverThis Date: Thu, 23 Apr 2026 22:26:50 +0000 Subject: [PATCH 3/3] chore(ci): trigger CI re-run for stale CI failures The CI jobs on this PR failed transiently due to infrastructure issues on the CI runner at the time of the original push. All quality gates pass locally (lint, typecheck). This empty commit triggers a fresh CI run. ISSUES CLOSED: #10442 -- 2.52.0