fix(tui): correct preset cycling keybinding to ctrl+tab and add persona tab-cycling #10821

Closed
HAL9000 wants to merge 3 commits from feature/issue-9442-fix-tui-correct-preset-cycling-keybinding-to-ctrl-tab-and-add-persona-tab-cycling into master
9 changed files with 167 additions and 21 deletions
+2
View File
@@ -188,6 +188,8 @@ ensuring data is stored with proper parameter values.
- **A2A module rename BDD test suite** (#8615): Comprehensive Behave tests validating that the ACP→A2A module rename is complete — verifying all 22 A2A symbols are properly exported, no legacy ACP references remain in `.py` files under `cleveragents.a2a/`, and the module docstring uses current A2A naming. The step definitions include self-contained symbol lookups to avoid cross-scenario dependency failures.
- **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.
+1
View File
@@ -28,6 +28,7 @@ Below are some of the specific details of various contributions.
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
* Jeffrey Phillips Freeman has contributed the invariant merge precedence fix (#9126): restored the missing ACTION scope in ``merge_invariants()`` and ``InvariantSet.merge()``, corrected all module docstrings from ``plan > project > global`` to the spec-compliant ``plan > action > project > global``, and added comprehensive BDD test coverage for four-tier merge precedence.
* 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, including fix for PermissionsScreen base class (#10744 / #10488): converted PermissionsScreen from a Static widget to a proper Textual Screen subclass with full keyboard bindings and action methods.
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
+6
View File
@@ -683,3 +683,9 @@ def step_slash_overlay_all_commands(context) -> None:
# (set_commands with empty query calls hide(), clearing _text and _commands)
assert not overlay._visible, "Slash overlay should be hidden after reset"
assert overlay._text == "", "Slash overlay text should be empty after reset"
# 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()
@@ -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"
+10 -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 4 key bindings
And the app class should have 5 key bindings
# --- compose method (lines 102-112) ---
@@ -269,3 +269,12 @@ Feature: TUI App Coverage
And I trigger on_input_changed with empty text
Then the slash overlay should be reset to all commands
And the reference picker should be reset to empty
# --- 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
1
@@ -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"
+6 -1
View File
1
@@ -272,7 +272,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"),
("escape", "escape", "Close Overlay"),
]
@@ -354,6 +355,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 action_escape(self) -> None:
prompt = self.query_one("#prompt", PromptInput)
+25 -18
View File
1
@@ -81,31 +81,38 @@ class PersonaState:
self.preset_by_session[session_id] = next_name
return next_name
def cycle_persona(self, session_id: str) -> Persona:
"""Cycle to the next persona in cycle_order sequence.
def cycle_persona(self, session_id: str) -> str:
"""Cycle to the next available persona.
Only personas with cycle_order > 0 are included in the cycle.
If no cyclic personas exist, returns the current active persona.
Returns the name of the newly active persona.
"""
personas = self.registry.list_personas()
cyclic = sorted(
[p for p in personas if p.cycle_order > 0], key=lambda p: p.cycle_order
)
if not cyclic:
return self.active_persona(session_id)
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)
current_names = [p.name for p in cyclic]
if current not in current_names:
# Current persona is not in cycle, start from first
next_persona = cyclic[0]
else:
idx = current_names.index(current)
next_persona = cyclic[(idx + 1) % len(cyclic)]
# 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]
return self.set_active_persona(session_id, next_persona.name)
# 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)
1
@@ -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"),