fix(tui): correct preset cycling keybinding to ctrl+tab and add persona tab-cycling #10821
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user