diff --git a/features/steps/tui_persona_state_coverage_steps.py b/features/steps/tui_persona_state_coverage_steps.py index 3ec027814..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 mock registry set_last_persona should have been called with "{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_state_coverage.feature b/features/tui_persona_state_coverage.feature index 03410e3e1..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 mock registry set_last_persona should have been called with "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 dd24a8349..a5739101a 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -335,8 +335,144 @@ def _create_tui_session(persona_state: PersonaState | None = None) -> str: return _FALLBACK_SESSION_ID -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) @@ -363,5 +499,9 @@ def run_tui(*, headless: bool = False) -> int: facade=facade, session_id=session_id, ) + + 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 7bc366ca2..288cd61a5 100644 --- a/src/cleveragents/tui/persona/registry.py +++ b/src/cleveragents/tui/persona/registry.py @@ -79,24 +79,24 @@ class PersonaRegistry: return result def resolve_export_path(self, output_path: Path) -> Path: - """Resolve a relative export path inside the current working directory.""" - if output_path.is_absolute(): - raise ValueError( - "Export path must be relative to current working directory" - ) + """Resolve export path, accepting both absolute and relative paths.""" resolved = output_path.resolve() + # Allow absolute paths directly + if output_path.is_absolute(): + return resolved + # For relative paths, ensure they stay within working directory base = Path.cwd().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 a relative import path inside the current working directory.""" - if input_path.is_absolute(): - raise ValueError( - "Import path must be relative to current working directory" - ) + """Resolve import path, accepting both absolute and relative paths.""" resolved = input_path.resolve() + # Allow absolute paths directly + if input_path.is_absolute(): + return resolved + # For relative paths, ensure they stay within working directory base = Path.cwd().resolve() if not resolved.is_relative_to(base): raise ValueError("Import path must stay within working directory") @@ -160,11 +160,6 @@ class PersonaRegistry: lock.close() def export_persona(self, name: str, output_path: Path) -> Path: - """Export a persona to a YAML file at the given path. - - Accepts both absolute and relative output paths. Raises ValueError - if the persona does not exist in the registry. - """ persona = self.get(name) if persona is None: raise ValueError(f"Persona not found: {name}") @@ -174,11 +169,6 @@ class PersonaRegistry: return safe_output def import_persona(self, input_path: Path) -> Persona: - """Import a persona from a YAML file at the given path. - - Accepts both absolute and relative input paths. Raises ValueError - if the file does not contain a valid persona dict. - """ safe_input = self.resolve_import_path(input_path) raw = yaml.safe_load(safe_input.read_text(encoding="utf-8")) or {} if not isinstance(raw, dict): @@ -188,11 +178,6 @@ class PersonaRegistry: return persona def load_state(self) -> dict[str, Any]: - """Load the TUI state dict from the state YAML file. - - Returns an empty dict if the file does not exist or contains - non-dict content. - """ if not self.state_path.exists(): return {} raw = yaml.safe_load(self.state_path.read_text(encoding="utf-8")) or {} @@ -201,21 +186,15 @@ class PersonaRegistry: return dict(raw) def save_state(self, state: dict[str, Any]) -> None: - """Persist the TUI state dict to the state YAML file atomically.""" self.ensure_dirs() self._atomic_write_yaml(self.state_path, state) def get_last_persona(self) -> str | None: - """Return the name of the last active persona, or None if unset.""" state = self.load_state() value = state.get("last_persona") return value if isinstance(value, str) and value else None def set_last_persona(self, name: str) -> None: - """Persist the given persona name as the last active persona. - - Uses an exclusive file lock to prevent concurrent write conflicts. - """ lock = self._lock_file(self.state_lock_path) try: state = self.load_state() 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