From 411604b50dd75da4fbd659e0b1ab11493fbd691f Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Fri, 8 May 2026 14:40:52 +0000 Subject: [PATCH] fix(tui): correct preset cycling keybinding to ctrl+tab and add persona tab-cycling Change argument preset cycling shortcut from ctrl+t to ctrl+tab for terminal compatibility (avoids conflicts with terminal session managers that also use ctrl+t). Add shift+tab binding to cycle through available personas in the registry, automatically resetting preset to default when switching personas. Help panel now reflects both keybindings under Main Screen context. ISSUES CLOSED: #9442 --- CHANGELOG.md | 2 + CONTRIBUTORS.md | 1 + features/steps/tui_app_coverage_steps.py | 6 ++ .../steps/tui_persona_state_coverage_steps.py | 84 +++++++++++++++++++ features/tui_app_coverage.feature | 11 ++- features/tui_persona_state_coverage.feature | 31 +++++++ src/cleveragents/tui/app.py | 7 +- src/cleveragents/tui/persona/state.py | 33 ++++++++ .../tui/widgets/help_panel_overlay.py | 3 +- 9 files changed, 175 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fb6ad03..eeddd54eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +- **TUI preset cycling keybinding corrected to ctrl+tab and persona tab-cycling added** (#9442): Changed the argument preset cycling shortcut from ``ctrl+t`` to ``ctrl+tab`` to avoid conflicts with terminal session managers that also use ``ctrl+t`` for alternate file sender. Added a new ``shift+tab`` binding to cycle through available personas in the registry, automatically resetting the preset to ``default`` when switching personas. Help panel now reflects both keybindings under Main Screen context. + - Fixed `ReactiveEventBus.emit()` exception handler to log the full exception message (`str(exc)`) and enable traceback forwarding (`exc_info=True`). Previously the handler logged only the exception type name (e.g. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 358d21b58..4f817a521 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -13,6 +13,7 @@ Below are some of the specific details of various contributions. * Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner. +* HAL 9000 has contributed the TUI preset cycling keybinding fix (PR #9442): corrected the argument preset cycling shortcut from ``ctrl+t`` to ``ctrl+tab`` for safer terminal compatibility, added a ``shift+tab`` binding to cycle through available personas in the registry with automatic preset reset to ``default`` on switch, and updated the help panel overlay to document both new bindings under Main Screen context. * Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements. * HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool. * HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution. diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index fd9d5c18c..85641ffbd 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -520,3 +520,9 @@ def step_theme_class_var(context, theme: str) -> None: assert theme == context._tui_app.THEME, ( f"Expected THEME='{theme}', got '{context._tui_app.THEME}'" ) + + +# action_cycle_persona (new) +@when("I call action_cycle_persona on the app") +def step_call_action_cycle_persona(context): + context._tui_app.action_cycle_persona() diff --git a/features/steps/tui_persona_state_coverage_steps.py b/features/steps/tui_persona_state_coverage_steps.py index c9153f84a..62aaf5341 100644 --- a/features/steps/tui_persona_state_coverage_steps.py +++ b/features/steps/tui_persona_state_coverage_steps.py @@ -353,3 +353,87 @@ def step_verify_effective_arguments(context): assert context.result_eff_args["model"] == "gpt-4" assert context.result_eff_args["temp"] == 0.7 assert context.result_eff_args["speed"] == "fast" + + +# --------------------------------------------------------------------------- +# Scenario: cycle_persona cycles through available personas +# --------------------------------------------------------------------------- + + +@given('personas "{persona_csv}" are available in the registry') +def step_personas_available(context, persona_csv): + """Set up a registry with multiple personas.""" + persona_names = [n.strip() for n in persona_csv.split(",")] + personas = {name: _make_persona(name, actor=f"ns/{name}") for name in persona_names} + + registry = MagicMock() + registry.list_personas.return_value = list(personas.values()) + registry.get.side_effect = lambda name: personas.get(name) + default_persona = None + for n in ("default", "worker"): + if n in personas: + default_persona = personas[n] + break + if default_persona is None: + default_persona = _make_persona("default") + registry.ensure_default.return_value = default_persona + registry.set_last_persona = MagicMock() + + context.mock_registry = registry + context.state = PersonaState(registry=registry) + context.available_personas = personas + + +@given('session "{session_id}" has active persona "{name}"') +def step_session_has_persona(context, session_id, name): + """Set a session to have a specific active persona.""" + context.state.active_by_session[session_id] = name + + +@when('I cycle the persona for session "{session_id}"') +def step_cycle_persona(context, session_id): + """Call cycle_persona and store the result.""" + context.result_cycled_persona = context.state.cycle_persona(session_id) + context.last_session_id = session_id + + +@then('the cycled persona should be "{expected}"') +def step_verify_cycled_persona(context, expected): + """Verify the cycled persona name.""" + assert context.result_cycled_persona == expected + + +@given("no personas are available in the registry") +def step_no_personas_available(context): + """Set up a registry with no personas.""" + registry = MagicMock() + registry.list_personas.return_value = [] + default_persona = _make_persona("default") + registry.ensure_default.return_value = default_persona + registry.get.return_value = default_persona + registry.set_last_persona = MagicMock() + + context.mock_registry = registry + context.state = PersonaState(registry=registry) + + +@given('session "{session_id}" has active persona "{name}" with preset "{preset}"') +def step_session_has_persona_with_preset(context, session_id, name, preset): + """Set a session to have a specific persona and preset.""" + context.state.active_by_session[session_id] = name + context.state.preset_by_session[session_id] = preset + + +@then("the registry last persona should be set to {persona}") +def step_verify_last_persona_set(context, persona): + """Verify the registry's set_last_persona was called.""" + assert context.mock_registry.set_last_persona is not None + calls = context.mock_registry.set_last_persona.call_args_list + assert len(calls) > 0 + assert calls[-1][0][0] == persona[1:-1] # strip quotes + + +@then('the preset for session "{session_id}" should be "default"') +def step_verify_preset_reset(context, session_id): + """Verify the preset was reset to default after persona switch.""" + assert context.state.preset_by_session[session_id] == "default" diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index a610e42ea..08aef4bcc 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -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) --- @@ -204,3 +204,12 @@ 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 THEME set to "dracula" + + # --- action_cycle_persona method (new) --- + + Scenario: action_cycle_persona cycles the persona and refreshes the bar + Given a mock command router and persona state + 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 persona bar content should be refreshed diff --git a/features/tui_persona_state_coverage.feature b/features/tui_persona_state_coverage.feature index 5141c737b..734f60cda 100644 --- a/features/tui_persona_state_coverage.feature +++ b/features/tui_persona_state_coverage.feature @@ -66,3 +66,34 @@ Feature: TUI Persona State Coverage Given a persona with base arguments and presets is active for session "sess-11" When I request the effective arguments for session "sess-11" Then the effective arguments should merge base and preset overrides + + Scenario: cycle_persona cycles through available personas + Given personas "alice,bob,charlie" are available in the registry + And session "sess-12" has active persona "alice" + When I cycle the persona for session "sess-12" + Then the cycled persona should be "bob" + AND the registry last persona should be set to "bob" + + Scenario: cycle_persona wraps around to first persona + Given personas "alice,bob,charlie" are available in the registry + And session "sess-13" has active persona "charlie" + When I cycle the persona for session "sess-13" + Then the cycled persona should be "alice" + + Scenario: cycle_persona handles single persona + Given personas "only-one" are available in the registry + And session "sess-14" has active persona "only-one" + When I cycle the persona for session "sess-14" + Then the cycled persona should be "only-one" + + Scenario: cycle_persona resets preset to default when switching personas + Given personas "alice,bob" are available in the registry + AND session "sess-15" has active persona "alice" with preset "turbo" + When I cycle the persona for session "sess-15" + Then the cycled persona should be "bob" + AND the preset for session "sess-15" should be "default" + + Scenario: cycle_persona handles empty persona list + Given no personas are available in the registry + When I cycle the persona for session "sess-16" + Then the cycled persona should be "default" diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 68401dedd..59dc8c5af 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -93,7 +93,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"), + ("shift+tab", "cycle_persona", "Cycle Persona"), ] def __init__( @@ -153,6 +154,10 @@ if _TEXTUAL_AVAILABLE: self._persona_state.cycle_preset(self._session.session_id) self._refresh_persona_bar() + def action_cycle_persona(self) -> None: + self._persona_state.cycle_persona(self._session.session_id) + 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) diff --git a/src/cleveragents/tui/persona/state.py b/src/cleveragents/tui/persona/state.py index c11fa3fcb..f7f327aae 100644 --- a/src/cleveragents/tui/persona/state.py +++ b/src/cleveragents/tui/persona/state.py @@ -63,6 +63,39 @@ class PersonaState: self.preset_by_session[session_id] = next_name return next_name + def cycle_persona(self, session_id: str) -> str: + """Cycle to the next available persona. + + Returns the name of the newly active persona. + """ + personas = self.registry.list_personas() + if not personas: + # No personas available, ensure default exists + default = self.registry.ensure_default() + self.active_by_session[session_id] = default.name + return default.name + + # Get list of persona names, sorted for consistent ordering + names = sorted([p.name for p in personas]) + current = self.active_name(session_id) + + # If current persona is not in the list, start with the first one + if current not in names: + self.active_by_session[session_id] = names[0] + self.registry.set_last_persona(names[0]) + # Reset preset when switching personas + self.preset_by_session[session_id] = "default" + return names[0] + + # Cycle to the next persona + idx = names.index(current) + next_name = names[(idx + 1) % len(names)] + self.active_by_session[session_id] = next_name + self.registry.set_last_persona(next_name) + # Reset preset when switching personas + self.preset_by_session[session_id] = "default" + return next_name + def effective_arguments(self, session_id: str) -> dict[str, object]: persona = self.active_persona(session_id) preset = self.current_preset(session_id) diff --git a/src/cleveragents/tui/widgets/help_panel_overlay.py b/src/cleveragents/tui/widgets/help_panel_overlay.py index a0351580b..636a1dcc5 100644 --- a/src/cleveragents/tui/widgets/help_panel_overlay.py +++ b/src/cleveragents/tui/widgets/help_panel_overlay.py @@ -32,7 +32,8 @@ _GLOBAL_ITEMS = ( _CONTEXT_ITEMS: dict[str, tuple[tuple[str, str], ...]] = { "Main Screen": ( ("enter", "Submit prompt"), - ("ctrl+t", "Cycle to next argument preset"), + ("ctrl+tab", "Cycle to next argument preset"), + ("shift+tab", "Cycle personas in registry"), ("@", "Open Reference Picker overlay"), ("/", "Open Slash Command overlay"), ("! / $", "Activate shell mode"),