fix(tui): correct preset cycling keybinding to ctrl+tab and add persona tab-cycling
CI / push-validation (pull_request) Successful in 20s
CI / helm (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 3m34s
CI / quality (pull_request) Successful in 3m38s
CI / typecheck (pull_request) Successful in 3m58s
CI / build (pull_request) Successful in 3m17s
CI / security (pull_request) Successful in 4m6s
CI / e2e_tests (pull_request) Successful in 6m21s
CI / integration_tests (pull_request) Successful in 7m15s
CI / unit_tests (pull_request) Successful in 8m10s
CI / docker (pull_request) Successful in 1m23s
CI / coverage (pull_request) Successful in 14m27s
CI / status-check (pull_request) Successful in 2s

Changed the preset cycling keybinding from ctrl+t to ctrl+tab in the BINDINGS list to reflect the updated UX and standard keyboard navigation.

Added a new Tab keybinding for persona cycling to enable quick switching between personas without leaving the TUI.

Implemented action_cycle_persona method that cycles through personas with cycle_order > 0, sorted by cycle_order to ensure deterministic cycling behavior.

Added BDD test scenarios to verify that the keybindings work correctly for both preset cycling and persona cycling, including relevant edge cases.

Added step definitions for the new test scenarios to integrate the BDD tests with the TUI behavior.

ISSUES CLOSED: #9358
This commit is contained in:
2026-04-14 17:52:26 +00:00
parent 78cfdc9b1b
commit 40d5989f61
3 changed files with 103 additions and 2 deletions
+62
View File
@@ -510,3 +510,65 @@ def step_alias_check(context):
assert (
context._tui_app_mod.CleverAgentsTuiApp.__name__ == "_TextualCleverAgentsTuiApp"
)
# --- action_cycle_persona keybinding ---
@then('the app class should have a keybinding for "{key}" to "{action}"')
def step_check_keybinding(context, key, action):
"""Verify that a specific keybinding exists."""
app_class = context._tui_app_mod._TextualCleverAgentsTuiApp
bindings = app_class.BINDINGS
found = any(b[0] == key and b[1] == action for b in bindings)
assert found, f"Keybinding {key} -> {action} not found in {bindings}"
@given("a mock command router and persona state with cycleable personas")
def step_create_cycleable_personas(context):
"""Create a persona state with multiple personas that have cycle_order > 0."""
from cleveragents.tui.persona.registry import PersonaRegistry
from cleveragents.tui.persona.schema import Persona
from cleveragents.tui.persona.state import PersonaState
tmp = tempfile.mkdtemp()
context._tui_tmpdir = tmp
registry = PersonaRegistry(config_dir=Path(tmp))
# Create default persona
registry.ensure_default()
# Create cycleable personas
persona1 = Persona(
name="persona1",
actor="local/test",
cycle_order=1,
)
persona2 = Persona(
name="persona2",
actor="local/test",
cycle_order=2,
)
registry.save(persona1)
registry.save(persona2)
context._tui_persona_state = PersonaState(registry=registry)
@when("I call action_cycle_persona on the app")
def step_call_action_cycle_persona(context):
"""Call the action_cycle_persona method on the app."""
context._tui_app.action_cycle_persona()
@then("the active persona should have changed")
def step_check_persona_changed(context):
"""Verify that the active persona has changed."""
# Get the current active persona
active = context._tui_persona_state.active_name(
context._tui_app._session.session_id
)
# Should be one of the cycleable personas
personas = context._tui_persona_state.registry.list_personas()
cycleable = [p for p in personas if p.cycle_order > 0]
assert any(p.name == active for p in cycleable), (
f"Active persona {active} is not cycleable"
)
+22 -1
View File
@@ -47,7 +47,7 @@ Feature: TUI App Coverage
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app class should have CSS_PATH set to "cleveragents.tcss"
And the app class should have 3 key bindings
And the app class should have 4 key bindings
# --- compose method (lines 102-112) ---
@@ -104,6 +104,13 @@ Feature: TUI App Coverage
# --- action_cycle_preset method (lines 127-129) ---
Scenario: action_cycle_preset uses ctrl+tab keybinding
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app class should have a keybinding for "ctrl+tab" to "cycle_preset"
# --- action_cycle_preset method (lines 127-129) ---
Scenario: action_cycle_preset cycles the preset and refreshes the bar
Given a mock command router and persona state
When I instantiate the Textual TUI app
@@ -111,6 +118,20 @@ Feature: TUI App Coverage
And I call action_cycle_preset on the app
Then the persona bar content should be refreshed
# --- action_cycle_persona method (new) ---
Scenario: action_cycle_persona uses tab keybinding
Given a mock command router and persona state
When I instantiate the Textual TUI app
Then the app class should have a keybinding for "tab" to "cycle_persona"
Scenario: action_cycle_persona cycles through personas with cycle_order > 0
Given a mock command router and persona state with cycleable personas
When I instantiate the Textual TUI app
And I call on_mount on the app
And I call action_cycle_persona on the app
Then the active persona should have changed
# --- _refresh_persona_bar method (lines 131-142) ---
Scenario: _refresh_persona_bar builds scope text from active persona
+19 -1
View File
@@ -92,7 +92,8 @@ if _TEXTUAL_AVAILABLE:
BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
("ctrl+q", "quit", "Quit"),
("f1", "help", "Help"),
("ctrl+t", "cycle_preset", "Cycle Preset"),
("ctrl+tab", "cycle_preset", "Cycle Preset"),
("tab", "cycle_persona", "Cycle Persona"),
]
def __init__(
@@ -152,6 +153,23 @@ if _TEXTUAL_AVAILABLE:
self._persona_state.cycle_preset(self._session.session_id)
self._refresh_persona_bar()
def action_cycle_persona(self) -> None:
"""Cycle through personas with cycle_order > 0, sorted by cycle_order."""
personas = self._persona_state.registry.list_personas()
cycleable = [p for p in personas if p.cycle_order > 0]
if not cycleable:
return
cycleable.sort(key=lambda p: p.cycle_order)
current_name = self._persona_state.active_name(self._session.session_id)
current_idx = next(
(i for i, p in enumerate(cycleable) if p.name == current_name), -1
)
next_persona = cycleable[(current_idx + 1) % len(cycleable)]
self._persona_state.set_active_persona(
self._session.session_id, next_persona.name
)
self._refresh_persona_bar()
def _refresh_persona_bar(self) -> None:
persona = self._persona_state.active_persona(self._session.session_id)
preset = self._persona_state.current_preset(self._session.session_id)