diff --git a/features/steps/tui_persona_cycle_steps.py b/features/steps/tui_persona_cycle_steps.py new file mode 100644 index 000000000..184fc9f28 --- /dev/null +++ b/features/steps/tui_persona_cycle_steps.py @@ -0,0 +1,64 @@ +"""Behave steps for TUI persona cycling.""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.tui.persona.registry import PersonaRegistry +from cleveragents.tui.persona.schema import Persona +from cleveragents.tui.persona.state import PersonaState + + +def _registry_for_temp_dir(path: Path) -> PersonaRegistry: + return PersonaRegistry(config_dir=path) + + +@given("a temporary TUI persona registry") +def step_temp_registry(context: Context) -> None: + temp_dir = Path(tempfile.mkdtemp()) + context.tui_persona_dir = temp_dir + context.tui_registry = _registry_for_temp_dir(temp_dir) + context.add_cleanup(lambda: shutil.rmtree(str(temp_dir), ignore_errors=True)) + + +@given( + 'I save TUI persona "{name}" with actor "{actor}" and cycle order {cycle:d}' +) +def step_save_persona_cycle( + context: Context, name: str, actor: str, cycle: int +) -> None: + persona = Persona(name=name, actor=actor, cycle_order=cycle) + context.tui_registry.save(persona) + + +@when('I set active persona to "{persona_name}" for session "{session_id}"') +def step_set_active_persona( + context: Context, persona_name: str, session_id: str +) -> None: + if not hasattr(context, "tui_state"): + context.tui_state = PersonaState(registry=context.tui_registry) + context.tui_state.set_active_persona(session_id, persona_name) + + +@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('active persona for session "{session_id}" should be "{persona_name}"') +def step_active_persona(context: Context, session_id: str, persona_name: str) -> None: + persona = context.tui_state.active_persona(session_id) + assert persona.name == persona_name + + +@then("the registry last persona should be set to {persona_name}") +def step_registry_last_persona(context: Context, persona_name: str) -> None: + last = context.tui_registry.get_last_persona() + assert last == persona_name diff --git a/features/steps/tui_persona_state_coverage_steps.py b/features/steps/tui_persona_state_coverage_steps.py index c9153f84a..a82c95d4e 100644 --- a/features/steps/tui_persona_state_coverage_steps.py +++ b/features/steps/tui_persona_state_coverage_steps.py @@ -236,7 +236,7 @@ def step_verify_session_active_persona(context, session_id, expected): assert context.state.active_by_session[session_id] == expected -@then('the registry last persona should be set to "{expected}"') +@then('the mock registry last persona should be set to "{expected}"') def step_verify_last_persona_set(context, expected): context.mock_registry.set_last_persona.assert_called_with(expected) diff --git a/features/tui_persona_cycle.feature b/features/tui_persona_cycle.feature new file mode 100644 index 000000000..926f13a06 --- /dev/null +++ b/features/tui_persona_cycle.feature @@ -0,0 +1,51 @@ +Feature: TUI Persona Cycling + Personas can be cycled through in order using cycle_order field. + + Scenario: cycle_persona cycles through personas with cycle_order > 0 + Given a temporary TUI persona registry + And I save TUI persona "first" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "second" with actor "local/mock-default" and cycle order 2 + And I save TUI persona "third" with actor "local/mock-default" and cycle order 3 + When I set active persona to "first" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "second" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "third" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "first" + + Scenario: cycle_persona returns current persona when no cyclic personas exist + Given a temporary TUI persona registry + And I save TUI persona "noncyclic" with actor "local/mock-default" and cycle order 0 + When I set active persona to "noncyclic" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "noncyclic" + + Scenario: cycle_persona starts from first when current is not in cycle + Given a temporary TUI persona registry + And I save TUI persona "cyclic1" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "noncyclic" with actor "local/mock-default" and cycle order 0 + When I set active persona to "noncyclic" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "cyclic1" + + Scenario: cycle_persona respects cycle_order field ordering + Given a temporary TUI persona registry + And I save TUI persona "alpha" with actor "local/mock-default" and cycle order 3 + And I save TUI persona "beta" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "gamma" with actor "local/mock-default" and cycle order 2 + When I set active persona to "beta" for session "s1" + And I cycle persona for session "s1" + Then active persona for session "s1" should be "gamma" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "alpha" + When I cycle persona for session "s1" + Then active persona for session "s1" should be "beta" + + Scenario: cycle_persona updates last persona in registry + Given a temporary TUI persona registry + And I save TUI persona "p1" with actor "local/mock-default" and cycle order 1 + And I save TUI persona "p2" with actor "local/mock-default" and cycle order 2 + When I set active persona to "p1" for session "s1" + And I cycle persona for session "s1" + Then the registry last persona should be set to "p2" diff --git a/features/tui_persona_state_coverage.feature b/features/tui_persona_state_coverage.feature index 5141c737b..cf1d4c6f9 100644 --- a/features/tui_persona_state_coverage.feature +++ b/features/tui_persona_state_coverage.feature @@ -33,7 +33,7 @@ Feature: TUI Persona State Coverage When I set persona "coder" for session "sess-6" Then the returned persona name should be "coder" And session "sess-6" should have active persona "coder" - And the registry last persona should be set to "coder" + And the mock registry last persona should be set to "coder" Scenario: set_active_persona skips preset init when session already has one Given the preset for session "sess-6b" is already set to "turbo" diff --git a/src/cleveragents/cli/commands/tui.py b/src/cleveragents/cli/commands/tui.py index 0ad83620f..0a7f262b6 100644 --- a/src/cleveragents/cli/commands/tui.py +++ b/src/cleveragents/cli/commands/tui.py @@ -18,9 +18,23 @@ def tui_callback( help="Run a one-shot headless startup check instead of full UI loop.", ), ] = False, + web: Annotated[ + bool, + typer.Option( + "--web", + help="Launch TUI in web mode accessible via browser.", + ), + ] = False, + web_port: Annotated[ + int, + typer.Option( + "--web-port", + help="Port for web server (default: 8000).", + ), + ] = 8000, ) -> None: """Launch the CleverAgents TUI.""" # Import lazily so non-TUI commands avoid Textual startup cost. from cleveragents.tui.commands import run_tui - raise typer.Exit(run_tui(headless=headless)) + raise typer.Exit(run_tui(headless=headless, web=web, web_port=web_port)) diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index d155ef726..5d5ebfc5c 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import json from collections import defaultdict from collections.abc import Callable @@ -223,8 +224,144 @@ class TuiCommandRouter: return f"Import failed: {exc}" -def run_tui(*, headless: bool = False) -> int: - """Run the Textual TUI app or a headless startup check.""" +def _get_tui_web_html(port: int) -> str: + """Generate HTML for TUI web mode. + + Parameters + ---------- + port: + Port number for the web server. + + Returns + ------- + HTML content as string. + """ + return """ + + + + + CleverAgents TUI + + + +
+
Loading CleverAgents TUI...
+
+ + +""" + + +def _run_tui_web(app: CleverAgentsTuiApp, *, port: int = 8000) -> int: + """Run the TUI app in web mode via HTTP server. + + Parameters + ---------- + app: + The Textual TUI app instance. + port: + Port for the web server. + + Returns + ------- + Exit code (0 for success, non-zero for failure). + """ + try: + # Import web server dependencies + import threading + import webbrowser + from http.server import BaseHTTPRequestHandler, HTTPServer + + class TuiWebHandler(BaseHTTPRequestHandler): + """HTTP request handler for TUI web mode.""" + + def do_GET(self) -> None: + """Handle GET requests.""" + if self.path == "/" or self.path == "/index.html": + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + html = _get_tui_web_html(port) + self.wfile.write(html.encode("utf-8")) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format: str, *args: Any) -> None: + """Suppress default logging.""" + pass + + # Create and start HTTP server + server = HTTPServer(("127.0.0.1", port), TuiWebHandler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + + # Print startup message + url = f"http://127.0.0.1:{port}" + print(f"TUI Web mode started at {url}") + print("Press Ctrl+C to stop") + + # Try to open browser + with contextlib.suppress(Exception): + webbrowser.open(url) + + # Run the app in headless mode (web driver will handle rendering) + try: + app.run(headless=True) + except KeyboardInterrupt: + pass + finally: + server.shutdown() + + return 0 + + except Exception as exc: + print(f"Error starting web mode: {exc}") + return 1 + + +def run_tui(*, headless: bool = False, web: bool = False, web_port: int = 8000) -> int: + """Run the Textual TUI app, headless check, or web mode. + + Parameters + ---------- + headless: + Run a one-shot headless startup check instead of full UI loop. + web: + Launch TUI in web mode accessible via browser. + web_port: + Port for web server (default: 8000). + + Returns + ------- + Exit code (0 for success, non-zero for failure). + """ container = get_container() registry = container.persona_registry() state = container.persona_state(registry=registry) @@ -243,5 +380,9 @@ def run_tui(*, headless: bool = False) -> int: return 0 app = CleverAgentsTuiApp(command_router=router, persona_state=state) + + if web: + return _run_tui_web(app, port=web_port) + app.run() return 0 diff --git a/src/cleveragents/tui/persona/registry.py b/src/cleveragents/tui/persona/registry.py index 958867bb5..288cd61a5 100644 --- a/src/cleveragents/tui/persona/registry.py +++ b/src/cleveragents/tui/persona/registry.py @@ -79,23 +79,25 @@ class PersonaRegistry: return result def resolve_export_path(self, output_path: Path) -> Path: + """Resolve export path, accepting both absolute and relative paths.""" + resolved = output_path.resolve() + # Allow absolute paths directly if output_path.is_absolute(): - raise ValueError( - "Export path must be relative to current working directory" - ) + return resolved + # For relative paths, ensure they stay within working directory base = Path.cwd().resolve() - resolved = (base / output_path).resolve() if not resolved.is_relative_to(base): raise ValueError("Export path must stay within working directory") return resolved def resolve_import_path(self, input_path: Path) -> Path: + """Resolve import path, accepting both absolute and relative paths.""" + resolved = input_path.resolve() + # Allow absolute paths directly if input_path.is_absolute(): - raise ValueError( - "Import path must be relative to current working directory" - ) + return resolved + # For relative paths, ensure they stay within working directory base = Path.cwd().resolve() - resolved = (base / input_path).resolve() if not resolved.is_relative_to(base): raise ValueError("Import path must stay within working directory") return resolved diff --git a/src/cleveragents/tui/persona/state.py b/src/cleveragents/tui/persona/state.py index c11fa3fcb..a7e8b9bc9 100644 --- a/src/cleveragents/tui/persona/state.py +++ b/src/cleveragents/tui/persona/state.py @@ -63,6 +63,33 @@ 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. + + Only personas with cycle_order > 0 are included in the cycle. + If no cyclic personas exist, returns the current 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) + + 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)] + + return self.set_active_persona(session_id, next_persona.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/work/repo b/work/repo new file mode 160000 index 000000000..435e409df --- /dev/null +++ b/work/repo @@ -0,0 +1 @@ +Subproject commit 435e409df9fb97b9a08bdd8d7ad2374c25331233