From 9ae40dda50536de03abd599744722dcf2b1f8b9d Mon Sep 17 00:00:00 2001 From: phact Date: Tue, 16 Sep 2025 11:43:13 -0400 Subject: [PATCH] misc improvements --- src/tui/main.py | 81 ++++++++- src/tui/screens/monitor.py | 5 - src/tui/screens/welcome.py | 282 +++++++++++++++++++++++++++---- src/tui/widgets/command_modal.py | 9 +- 4 files changed, 334 insertions(+), 43 deletions(-) diff --git a/src/tui/main.py b/src/tui/main.py index dd2f9e5f..c2a785f3 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -27,7 +27,7 @@ class OpenRAGTUI(App): CSS = """ Screen { - background: $background; + background: #0f172a; } #main-container { @@ -115,7 +115,8 @@ class OpenRAGTUI(App): } #services-table { - height: 1fr; + height: auto; + max-height: 12; margin-bottom: 1; } @@ -175,6 +176,82 @@ class OpenRAGTUI(App): height: 100%; padding: 1; } + + /* Frontend-inspired color scheme */ + Static { + color: #f1f5f9; + } + + Button.success { + background: #4ade80; + color: #000; + } + + Button.error { + background: #ef4444; + color: #fff; + } + + Button.warning { + background: #eab308; + color: #000; + } + + Button.primary { + background: #2563eb; + color: #fff; + } + + Button.default { + background: #475569; + color: #f1f5f9; + border: solid #64748b; + } + + DataTable { + background: #1e293b; + color: #f1f5f9; + } + + DataTable > .datatable--header { + background: #334155; + color: #f1f5f9; + } + + DataTable > .datatable--cursor { + background: #475569; + } + + Input { + background: #334155; + color: #f1f5f9; + border: solid #64748b; + } + + Label { + color: #f1f5f9; + } + + Footer { + background: #334155; + color: #f1f5f9; + } + + #runtime-status { + background: #1e293b; + border: solid #64748b; + color: #f1f5f9; + } + + #system-info { + background: #1e293b; + border: solid #64748b; + color: #f1f5f9; + } + + #services-table, #images-table { + background: #1e293b; + } """ def __init__(self): diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index 35f484e1..372abc27 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -273,8 +273,6 @@ class MonitorScreen(Screen): self.run_worker(self._stop_docling_serve()) elif button_id.startswith("docling-restart-btn"): self.run_worker(self._restart_docling_serve()) - elif button_id.startswith("docling-logs-btn"): - self._view_docling_logs() elif button_id == "toggle-mode-btn": self.action_toggle_mode() elif button_id.startswith("refresh-btn"): @@ -621,9 +619,6 @@ class MonitorScreen(Screen): docling_controls.mount( Button("Restart", variant="primary", id=f"docling-restart-btn{suffix}") ) - docling_controls.mount( - Button("View Logs", variant="default", id=f"docling-logs-btn{suffix}") - ) else: docling_controls.mount( Button("Start", variant="success", id=f"docling-start-btn{suffix}") diff --git a/src/tui/screens/welcome.py b/src/tui/screens/welcome.py index 0d4109b7..c93f5561 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -12,6 +12,8 @@ from dotenv import load_dotenv from ..managers.container_manager import ContainerManager, ServiceStatus from ..managers.env_manager import EnvManager +from ..managers.docling_manager import DoclingManager +from ..widgets.command_modal import CommandOutputModal class WelcomeScreen(Screen): @@ -22,15 +24,19 @@ class WelcomeScreen(Screen): ("enter", "default_action", "Continue"), ("1", "no_auth_setup", "Basic Setup"), ("2", "full_setup", "Advanced Setup"), - ("3", "monitor", "Monitor Services"), + ("3", "monitor", "Status"), ("4", "diagnostics", "Diagnostics"), + ("5", "start_stop_services", "Start/Stop Services"), + ("6", "open_app", "Open App"), ] def __init__(self): super().__init__() self.container_manager = ContainerManager() self.env_manager = EnvManager() + self.docling_manager = DoclingManager() self.services_running = False + self.docling_running = False self.has_oauth_config = False self.default_button_id = "basic-setup-btn" self._state_checked = False @@ -38,8 +44,16 @@ class WelcomeScreen(Screen): # Load .env file if it exists load_dotenv() + # Check OAuth config immediately + self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( + os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") + ) + def compose(self) -> ComposeResult: """Create the welcome screen layout.""" + # Try to detect services synchronously before creating buttons + self._detect_services_sync() + yield Container( Vertical( Static(self._create_welcome_text(), id="welcome-text"), @@ -50,6 +64,46 @@ class WelcomeScreen(Screen): ) yield Footer() + def _detect_services_sync(self) -> None: + """Synchronously detect if services are running.""" + if not self.container_manager.is_available(): + self.services_running = False + self.docling_running = self.docling_manager.is_running() + return + + try: + # Use synchronous docker command to check services + import subprocess + result = subprocess.run( + ["docker", "compose", "ps", "--format", "json"], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + import json + services = [] + for line in result.stdout.strip().split('\n'): + if line.strip(): + try: + service = json.loads(line) + services.append(service) + except json.JSONDecodeError: + continue + + # Check if any services are running + running_services = [s for s in services if s.get('State') == 'running'] + self.services_running = len(running_services) > 0 + else: + self.services_running = False + except Exception: + # Fallback to False if detection fails + self.services_running = False + + # Update native service state as part of detection + self.docling_running = self.docling_manager.is_running() + def _create_welcome_text(self) -> Text: """Create a minimal welcome message.""" welcome_text = Text() @@ -61,7 +115,7 @@ class WelcomeScreen(Screen): ╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║██║ ██║╚██████╔╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ """ - welcome_text.append(ascii_art, style="bold blue") + welcome_text.append(ascii_art, style="bold white") welcome_text.append("Terminal User Interface for OpenRAG\n\n", style="dim") if self.services_running: @@ -87,28 +141,56 @@ class WelcomeScreen(Screen): buttons = [] if self.services_running: - # Services running - only show monitor + # Services running - show app link first, then stop services buttons.append( - Button("Monitor Services", variant="success", id="monitor-btn") + Button("Launch OpenRAG", variant="success", id="open-app-btn") + ) + buttons.append( + Button("Stop Container Services", variant="error", id="stop-services-btn") ) else: - # Services not running - show setup options + # Services not running - show setup options and start services if has_oauth: - # Only show advanced setup if OAuth is configured + # If OAuth is configured, only show advanced setup buttons.append( Button("Advanced Setup", variant="success", id="advanced-setup-btn") ) else: - # Only show basic setup if no OAuth + # If no OAuth, show both options with basic as primary buttons.append( Button("Basic Setup", variant="success", id="basic-setup-btn") ) + buttons.append( + Button("Advanced Setup", variant="default", id="advanced-setup-btn") + ) - # Always show monitor option buttons.append( - Button("Monitor Services", variant="default", id="monitor-btn") + Button("Start Container Services", variant="primary", id="start-services-btn") ) + # Native services controls + if self.docling_running: + buttons.append( + Button( + "Stop Native Services", + variant="warning", + id="stop-native-services-btn", + ) + ) + else: + buttons.append( + Button( + "Start Native Services", + variant="primary", + id="start-native-services-btn", + ) + ) + + # Always show status option + buttons.append( + Button("Status", variant="default", id="status-btn") + ) + return Horizontal(*buttons, classes="button-row") async def on_mount(self) -> None: @@ -121,6 +203,10 @@ class WelcomeScreen(Screen): ] self.services_running = len(running_services) > 0 + # Check native service state + self.docling_running = self.docling_manager.is_running() + + # Check for OAuth configuration self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") @@ -128,38 +214,34 @@ class WelcomeScreen(Screen): # Set default button focus if self.services_running: - self.default_button_id = "monitor-btn" + self.default_button_id = "open-app-btn" elif self.has_oauth_config: self.default_button_id = "advanced-setup-btn" else: self.default_button_id = "basic-setup-btn" - # Update the welcome text and recompose with new state + # Update the welcome text try: welcome_widget = self.query_one("#welcome-text") - welcome_widget.update( - self._create_welcome_text() - ) # This is fine for Static widgets - - # Focus the appropriate button - if self.services_running: - try: - self.query_one("#monitor-btn").focus() - except: - pass - elif self.has_oauth_config: - try: - self.query_one("#advanced-setup-btn").focus() - except: - pass - else: - try: - self.query_one("#basic-setup-btn").focus() - except: - pass - + welcome_widget.update(self._create_welcome_text()) except: - pass # Widgets might not be mounted yet + pass # Widget might not be mounted yet + + # Focus the appropriate button (the buttons are created correctly in compose, + # the issue was they weren't being updated after service operations) + self.call_after_refresh(self._focus_appropriate_button) + + def _focus_appropriate_button(self) -> None: + """Focus the appropriate button based on current state.""" + try: + if self.services_running: + self.query_one("#open-app-btn").focus() + elif self.has_oauth_config: + self.query_one("#advanced-setup-btn").focus() + else: + self.query_one("#basic-setup-btn").focus() + except: + pass # Button might not exist def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" @@ -167,15 +249,25 @@ class WelcomeScreen(Screen): self.action_no_auth_setup() elif event.button.id == "advanced-setup-btn": self.action_full_setup() - elif event.button.id == "monitor-btn": + elif event.button.id == "status-btn": self.action_monitor() elif event.button.id == "diagnostics-btn": self.action_diagnostics() + elif event.button.id == "start-services-btn": + self.action_start_stop_services() + elif event.button.id == "stop-services-btn": + self.action_start_stop_services() + elif event.button.id == "start-native-services-btn": + self.action_start_native_services() + elif event.button.id == "stop-native-services-btn": + self.action_stop_native_services() + elif event.button.id == "open-app-btn": + self.action_open_app() def action_default_action(self) -> None: """Handle Enter key - go to default action based on state.""" if self.services_running: - self.action_monitor() + self.action_open_app() elif self.has_oauth_config: self.action_full_setup() else: @@ -205,6 +297,126 @@ class WelcomeScreen(Screen): self.app.push_screen(DiagnosticsScreen()) + def action_start_stop_services(self) -> None: + """Start or stop all services (containers + docling).""" + if self.services_running: + # Stop services - show modal with progress + if self.container_manager.is_available(): + command_generator = self.container_manager.stop_services() + modal = CommandOutputModal( + "Stopping Services", + command_generator, + on_complete=self._on_services_operation_complete, + ) + self.app.push_screen(modal) + else: + # Start services - show modal with progress + if self.container_manager.is_available(): + command_generator = self.container_manager.start_services() + modal = CommandOutputModal( + "Starting Services", + command_generator, + on_complete=self._on_services_operation_complete, + ) + self.app.push_screen(modal) + + async def _on_services_operation_complete(self) -> None: + """Handle completion of services start/stop operation.""" + # Use the same sync detection method that worked on startup + self._detect_services_sync() + + # Update OAuth config state + self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( + os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") + ) + + await self._refresh_welcome_content() + + def _update_default_button(self) -> None: + """Update the default button target based on state.""" + if self.services_running: + self.default_button_id = "open-app-btn" + elif self.has_oauth_config: + self.default_button_id = "advanced-setup-btn" + else: + self.default_button_id = "basic-setup-btn" + + async def _refresh_welcome_content(self) -> None: + """Refresh welcome text and buttons based on current state.""" + self._update_default_button() + + try: + welcome_widget = self.query_one("#welcome-text", Static) + welcome_widget.update(self._create_welcome_text()) + + welcome_container = self.query_one("#welcome-container") + + # Remove existing button rows before mounting updated row + for button_row in list(welcome_container.query(".button-row")): + await button_row.remove() + + await welcome_container.mount(self._create_dynamic_buttons()) + except Exception: + # Fallback - just refresh the whole screen + self.refresh(layout=True) + + self.call_after_refresh(self._focus_appropriate_button) + + def action_start_native_services(self) -> None: + """Start native services (docling).""" + if self.docling_running: + self.notify("Native services are already running.", severity="warning") + return + + self.run_worker(self._start_native_services()) + + async def _start_native_services(self) -> None: + """Worker task to start native services.""" + try: + success, message = await self.docling_manager.start() + if success: + self.docling_running = True + self.notify(message, severity="information") + else: + self.notify(f"Failed to start native services: {message}", severity="error") + except Exception as exc: + self.notify(f"Error starting native services: {exc}", severity="error") + finally: + self.docling_running = self.docling_manager.is_running() + await self._refresh_welcome_content() + + def action_stop_native_services(self) -> None: + """Stop native services (docling).""" + if not self.docling_running and not self.docling_manager.is_running(): + self.notify("Native services are not running.", severity="warning") + return + + self.run_worker(self._stop_native_services()) + + async def _stop_native_services(self) -> None: + """Worker task to stop native services.""" + try: + success, message = await self.docling_manager.stop() + if success: + self.docling_running = False + self.notify(message, severity="information") + else: + self.notify(f"Failed to stop native services: {message}", severity="error") + except Exception as exc: + self.notify(f"Error stopping native services: {exc}", severity="error") + finally: + self.docling_running = self.docling_manager.is_running() + await self._refresh_welcome_content() + + def action_open_app(self) -> None: + """Open the OpenRAG app in the default browser.""" + import webbrowser + try: + webbrowser.open("http://localhost:3000") + self.notify("Opening OpenRAG app in browser...", severity="information") + except Exception as e: + self.notify(f"Error opening app: {e}", severity="error") + def action_quit(self) -> None: """Quit the application.""" self.app.exit() diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index ccda113a..8f703648 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -1,6 +1,7 @@ """Command output modal dialog for OpenRAG TUI.""" import asyncio +import inspect from typing import Callable, List, Optional, AsyncIterator, Any from textual.app import ComposeResult @@ -122,7 +123,13 @@ class CommandOutputModal(ModalScreen): # Call the completion callback if provided if self.on_complete: await asyncio.sleep(0.5) # Small delay for better UX - self.on_complete() + + def _invoke_callback() -> None: + callback_result = self.on_complete() + if inspect.isawaitable(callback_result): + asyncio.create_task(callback_result) + + self.call_after_refresh(_invoke_callback) except Exception as e: output.write(f"[bold red]Error: {e}[/bold red]\n")