diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index ad9227bd2..dfa5fd4c2 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -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) # --------------------------------------------------------------------------- diff --git a/features/steps/tui_help_panel_overlay_coverage_steps.py b/features/steps/tui_help_panel_overlay_coverage_steps.py index b3190d1be..90ff748cf 100644 --- a/features/steps/tui_help_panel_overlay_coverage_steps.py +++ b/features/steps/tui_help_panel_overlay_coverage_steps.py @@ -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}" ) diff --git a/features/steps/tui_persona_system_steps.py b/features/steps/tui_persona_system_steps.py index 10a59a1dc..cc59c13f5 100644 --- a/features/steps/tui_persona_system_steps.py +++ b/features/steps/tui_persona_system_steps.py @@ -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 diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index 2392dca85..d2d2b63ca 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) --- @@ -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 diff --git a/features/tui_help_panel_overlay_coverage.feature b/features/tui_help_panel_overlay_coverage.feature index b7b2a23c4..429e7a46b 100644 --- a/features/tui_help_panel_overlay_coverage.feature +++ b/features/tui_help_panel_overlay_coverage.feature @@ -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 diff --git a/features/tui_persona_system.feature b/features/tui_persona_system.feature index 95605e5ac..ce3c696f0 100644 --- a/features/tui_persona_system.feature +++ b/features/tui_persona_system.feature @@ -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" diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 4a8c66dcf..722f57b3e 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -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() diff --git a/src/cleveragents/tui/persona/state.py b/src/cleveragents/tui/persona/state.py index c11fa3fcb..bcadffbb0 100644 --- a/src/cleveragents/tui/persona/state.py +++ b/src/cleveragents/tui/persona/state.py @@ -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" diff --git a/src/cleveragents/tui/widgets/help_panel_overlay.py b/src/cleveragents/tui/widgets/help_panel_overlay.py index a0351580b..41b511e2e 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"), + ("tab", "Cycle to next persona"), + ("ctrl+tab", "Cycle to next argument preset"), ("@", "Open Reference Picker overlay"), ("/", "Open Slash Command overlay"), ("! / $", "Activate shell mode"),