fix(tui): fix persona cycling to use ctrl+p keybinding (#6425)
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 27s
CI / helm (pull_request) Successful in 23s
CI / build (pull_request) Successful in 27s
CI / quality (pull_request) Successful in 34s
CI / push-validation (pull_request) Successful in 37s
CI / typecheck (pull_request) Successful in 1m7s
CI / security (pull_request) Successful in 1m7s
CI / e2e_tests (pull_request) Successful in 3m51s
CI / integration_tests (pull_request) Successful in 4m9s
CI / unit_tests (pull_request) Successful in 5m28s
CI / docker (pull_request) Successful in 1m19s
CI / coverage (pull_request) Successful in 10m47s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-regression (pull_request) Successful in 58m12s

Align TUI persona bindings with specification by cycling personas on tab and presets on ctrl+tab. Update persona state, help panel guidance, and Behave coverage to reflect new shortcuts.\n\nISSUES CLOSED: #6425
This commit is contained in:
2026-04-10 00:06:55 +00:00
parent 8a87675a58
commit 77a257d2d0
9 changed files with 95 additions and 10 deletions
+7 -2
View File
@@ -5,8 +5,8 @@ These steps target uncovered lines in cleveragents/tui/app.py:
- Lines 81-100: _TextualCleverAgentsTuiApp class definition + __init__
- Lines 102-112: compose method
- Lines 114-121: on_mount method
- Lines 123-125: action_help method
- Lines 127-129: action_cycle_preset method
- Lines 123-131: action_help and action_cycle_persona methods
- Lines 132-134: action_cycle_preset method
- Lines 131-142: _refresh_persona_bar method
- Lines 144-185: on_input_submitted (all branches)
- Line 189: CleverAgentsTuiApp alias
@@ -404,6 +404,11 @@ def step_persona_bar_refreshed(context):
assert bar._text # non-empty after refresh
@when("I call action_cycle_persona on the app")
def step_call_action_cycle_persona(context):
context._tui_app.action_cycle_persona()
# ---------------------------------------------------------------------------
# _refresh_persona_bar (lines 131-142)
# ---------------------------------------------------------------------------
@@ -2,6 +2,8 @@
from __future__ import annotations
import re
from behave import given, then, when
from cleveragents.tui.widgets.help_panel_overlay import (
@@ -42,14 +44,16 @@ def step_toggle_help_for_context(context, context_name):
@then('the standalone help panel text should contain "{text}"')
def step_standalone_help_panel_text_contains(context, text):
assert text in context.help_panel._text, (
pattern = rf"(?:^|\W){re.escape(text)}(?:\W|$)"
assert re.search(pattern, context.help_panel._text), (
f"Expected {text!r} in help panel text, got:\n{context.help_panel._text}"
)
@then('the standalone help panel text should not contain "{text}"')
def step_standalone_help_panel_text_not_contains(context, text):
assert text not in context.help_panel._text, (
pattern = rf"(?:^|\W){re.escape(text)}(?:\W|$)"
assert not re.search(pattern, context.help_panel._text), (
f"Expected {text!r} NOT in help panel text, but found it in:\n{context.help_panel._text}"
)
@@ -132,6 +132,13 @@ def step_cycle_preset(context: Context, session_id: str) -> None:
context.tui_state.cycle_preset(session_id)
@when('I cycle persona for session "{session_id}"')
def step_cycle_persona(context: Context, session_id: str) -> None:
if not hasattr(context, "tui_state"):
context.tui_state = PersonaState(registry=context.tui_registry)
context.tui_state.cycle_persona(session_id)
@then('current preset for session "{session_id}" should be "{preset_name}"')
def step_current_preset(context: Context, session_id: str, preset_name: str) -> None:
assert context.tui_state.current_preset(session_id) == preset_name
+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 3 key bindings
And the app class should have 4 key bindings
# --- compose method (lines 102-112) ---
@@ -102,6 +102,15 @@ Feature: TUI App Coverage
And I call action_help on the app
Then the help panel should be hidden on mount
# --- action_cycle_persona method (lines 126-130) ---
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
# --- action_cycle_preset method (lines 127-129) ---
Scenario: action_cycle_preset cycles the preset and refreshes the bar
@@ -29,9 +29,9 @@ Feature: TUI Help Panel Overlay Coverage
Scenario: help panel Main Screen keybindings match actual app BINDINGS
Given a fresh help panel overlay
When I show help for context "Main Screen"
Then the standalone help panel text should contain "ctrl+t"
And the standalone help panel text should not contain "ctrl+tab"
And the standalone help panel text should not contain "tab"
Then the standalone help panel text should contain "tab"
And the standalone help panel text should contain "ctrl+tab"
And the standalone help panel text should not contain "ctrl+t"
Scenario: help panel global keybindings match actual app BINDINGS
Given a fresh help panel overlay
+15
View File
@@ -35,6 +35,21 @@ Feature: TUI persona system
When I cycle persona preset for session "s1"
Then current preset for session "s1" should be "default"
Scenario: Persona state cycles through personas using configured order
Given a temporary TUI persona registry
And I save a TUI persona named "builder" with actor "local/mock-default" and cycle order 2
And I save a TUI persona named "reviewer" with actor "local/mock-default" and cycle order 1
And I save a TUI persona named "support" with actor "local/mock-default"
When I set active persona to "reviewer" for session "s1"
And I cycle persona for session "s1"
Then active persona for session "s1" should be "builder"
When I cycle persona for session "s1"
Then active persona for session "s1" should be "support"
When I cycle persona for session "s1"
Then active persona for session "s1" should be "default"
When I cycle persona for session "s1"
Then active persona for session "s1" should be "reviewer"
Scenario: Malformed persona YAML does not break registry operations
Given a temporary TUI persona registry
And I save a TUI persona named "good" with actor "local/mock-default"
+6 -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"),
("tab", "cycle_persona", "Cycle Persona"),
("ctrl+tab", "cycle_preset", "Cycle Preset"),
]
def __init__(
@@ -148,6 +149,10 @@ if _TEXTUAL_AVAILABLE:
context_name = resolve_help_context(prompt.value)
help_panel.toggle(context_name)
def action_cycle_persona(self) -> None:
self._persona_state.cycle_persona(self._session.session_id)
self._refresh_persona_bar()
def action_cycle_preset(self) -> None:
self._persona_state.cycle_preset(self._session.session_id)
self._refresh_persona_bar()
+39
View File
@@ -43,6 +43,45 @@ class PersonaState:
self.preset_by_session[session_id] = "default"
return persona
def cycle_persona(self, session_id: str) -> Persona:
"""Advance the active persona for *session_id* and return it.
Personas are cycled according to their configured ``cycle_order``.
Entries with a ``cycle_order`` value greater than zero are ordered
ascending by that value. Remaining personas retain a deterministic
order by name. The cycle always includes the default persona to
ensure the user can return to it, even if no explicit personas have
been configured.
"""
personas = self.registry.list_personas()
if not personas:
personas = [self.registry.ensure_default()]
# Guarantee the default persona participates in the cycle.
default_persona = self.registry.ensure_default()
persona_by_name = {persona.name: persona for persona in personas}
persona_by_name.setdefault(default_persona.name, default_persona)
def _sort_key(item: Persona) -> tuple[int, int, str]:
if item.cycle_order > 0:
return (0, item.cycle_order, item.name.lower())
is_default = 1 if item.name == "default" else 0
return (1, is_default, item.name.lower())
ordered = sorted(persona_by_name.values(), key=_sort_key)
active_name = self.active_name(session_id)
names = [persona.name for persona in ordered]
if active_name not in names:
next_persona = ordered[0]
else:
next_persona = ordered[(names.index(active_name) + 1) % len(ordered)]
persona = self.set_active_persona(session_id, next_persona.name)
self.preset_by_session[session_id] = "default"
return persona
def current_preset(self, session_id: str) -> str:
if session_id not in self.preset_by_session:
self.preset_by_session[session_id] = "default"
@@ -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"),
("tab", "Cycle to next persona"),
("ctrl+tab", "Cycle to next argument preset"),
("@", "Open Reference Picker overlay"),
("/", "Open Slash Command overlay"),
("! / $", "Activate shell mode"),