From ad572a7b23b53686a0eccb6c137c1da1d9ddc8b0 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 12:23:42 -0400 Subject: [PATCH 01/14] back to main, progress improvements for docker pull --- src/tui/managers/container_manager.py | 152 +++++++++++++++++--------- src/tui/screens/config.py | 6 +- src/tui/screens/welcome.py | 16 +++ src/tui/widgets/command_modal.py | 117 ++++++++++++-------- 4 files changed, 189 insertions(+), 102 deletions(-) diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index d1be1e9f..27b41d10 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -164,10 +164,15 @@ class ContainerManager: async def _run_compose_command_streaming( self, args: List[str], cpu_mode: Optional[bool] = None - ) -> AsyncIterator[str]: - """Run a compose command and yield output lines in real-time.""" + ) -> AsyncIterator[tuple[str, bool]]: + """Run a compose command and yield output with progress bar support. + + Yields: + Tuples of (message, replace_last) where replace_last indicates if the + message should replace the previous line (for progress updates) + """ if not self.is_available(): - yield "No container runtime available" + yield ("No container runtime available", False) return if cpu_mode is None: @@ -179,37 +184,58 @@ class ContainerManager: process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, # Combine stderr with stdout for unified output + stderr=asyncio.subprocess.STDOUT, cwd=Path.cwd(), ) - # Simple approach: read line by line and yield each one if process.stdout: + buffer = "" while True: - line = await process.stdout.readline() - if not line: + chunk = await process.stdout.read(1024) + if not chunk: + if buffer.strip(): + yield (buffer.strip(), False) break - line_text = line.decode(errors="ignore").rstrip() - if line_text: - yield line_text + buffer += chunk.decode(errors="ignore") + + while "\n" in buffer or "\r" in buffer: + cr_pos = buffer.find("\r") + nl_pos = buffer.find("\n") + + if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos): + line = buffer[:cr_pos] + buffer = buffer[cr_pos + 1:] + if line.strip(): + yield (line.strip(), True) + elif nl_pos != -1: + line = buffer[:nl_pos] + buffer = buffer[nl_pos + 1:] + if line.strip(): + yield (line.strip(), False) + else: + break - # Wait for process to complete await process.wait() except Exception as e: - yield f"Command execution failed: {e}" + yield (f"Command execution failed: {e}", False) async def _stream_compose_command( self, args: List[str], success_flag: Dict[str, bool], cpu_mode: Optional[bool] = None, - ) -> AsyncIterator[str]: - """Run compose command with live output and record success/failure.""" + ) -> AsyncIterator[tuple[str, bool]]: + """Run compose command with live output and record success/failure. + + Yields: + Tuples of (message, replace_last) where replace_last indicates if the + message should replace the previous line (for progress updates) + """ if not self.is_available(): success_flag["value"] = False - yield "No container runtime available" + yield ("No container runtime available", False) return if cpu_mode is None: @@ -226,32 +252,52 @@ class ContainerManager: ) except Exception as e: success_flag["value"] = False - yield f"Command execution failed: {e}" + yield (f"Command execution failed: {e}", False) return success_flag["value"] = True if process.stdout: + # Buffer to accumulate data for progress bar handling + buffer = "" while True: - line = await process.stdout.readline() - if not line: + chunk = await process.stdout.read(1024) + if not chunk: + # Process any remaining buffer content + if buffer.strip(): + yield (buffer.strip(), False) break - line_text = line.decode(errors="ignore") - # Compose often uses carriage returns for progress bars; normalise them - for chunk in line_text.replace("\r", "\n").split("\n"): - chunk = chunk.strip() - if not chunk: - continue - yield chunk - lowered = chunk.lower() - if "error" in lowered or "failed" in lowered: - success_flag["value"] = False + buffer += chunk.decode(errors="ignore") + + # Process complete lines or carriage return updates + while "\n" in buffer or "\r" in buffer: + # Check if we have a carriage return (progress update) before newline + cr_pos = buffer.find("\r") + nl_pos = buffer.find("\n") + + if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos): + # Carriage return found - extract and yield as replaceable line + line = buffer[:cr_pos] + buffer = buffer[cr_pos + 1:] + if line.strip(): + yield (line.strip(), True) # replace_last=True for progress updates + elif nl_pos != -1: + # Newline found - extract and yield as new line + line = buffer[:nl_pos] + buffer = buffer[nl_pos + 1:] + if line.strip(): + lowered = line.lower() + yield (line.strip(), False) # replace_last=False for new lines + if "error" in lowered or "failed" in lowered: + success_flag["value"] = False + else: + break returncode = await process.wait() if returncode != 0: success_flag["value"] = False - yield f"Command exited with status {returncode}" + yield (f"Command exited with status {returncode}", False) async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]: """Run a runtime command (docker/podman) and return (success, stdout, stderr).""" @@ -516,14 +562,14 @@ class ContainerManager: if hasattr(self, '_compose_search_log'): for line in self._compose_search_log.split('\n'): if line.strip(): - yield False, line + yield False, line, False - yield False, f"Final compose file: {compose_file.absolute()}" + yield False, f"Final compose file: {compose_file.absolute()}", False if not compose_file.exists(): - yield False, f"ERROR: Compose file not found at {compose_file.absolute()}" + yield False, f"ERROR: Compose file not found at {compose_file.absolute()}", False return - yield False, "Starting OpenRAG services..." + yield False, "Starting OpenRAG services...", False missing_images: List[str] = [] try: @@ -534,24 +580,24 @@ class ContainerManager: if missing_images: images_list = ", ".join(missing_images) - yield False, f"Pulling container images ({images_list})..." + yield False, f"Pulling container images ({images_list})...", False pull_success = {"value": True} - async for line in self._stream_compose_command( + async for message, replace_last in self._stream_compose_command( ["pull"], pull_success, cpu_mode ): - yield False, line + yield False, message, replace_last if not pull_success["value"]: - yield False, "Some images failed to pull; attempting to start services anyway..." + yield False, "Some images failed to pull; attempting to start services anyway...", False - yield False, "Creating and starting containers..." + yield False, "Creating and starting containers...", False up_success = {"value": True} - async for line in self._stream_compose_command(["up", "-d"], up_success, cpu_mode): - yield False, line + async for message, replace_last in self._stream_compose_command(["up", "-d"], up_success, cpu_mode): + yield False, message, replace_last if up_success["value"]: - yield True, "Services started successfully" + yield True, "Services started successfully", False else: - yield False, "Failed to start services. See output above for details." + yield False, "Failed to start services. See output above for details.", False async def stop_services(self) -> AsyncIterator[tuple[bool, str]]: """Stop all services and yield progress updates.""" @@ -581,35 +627,35 @@ class ContainerManager: self, cpu_mode: bool = False ) -> AsyncIterator[tuple[bool, str]]: """Upgrade services (pull latest images and restart) and yield progress updates.""" - yield False, "Pulling latest images..." + yield False, "Pulling latest images...", False # Pull latest images with streaming output pull_success = True - async for line in self._run_compose_command_streaming(["pull"], cpu_mode): - yield False, line + async for message, replace_last in self._run_compose_command_streaming(["pull"], cpu_mode): + yield False, message, replace_last # Check for error patterns in the output - if "error" in line.lower() or "failed" in line.lower(): + if "error" in message.lower() or "failed" in message.lower(): pull_success = False if not pull_success: - yield False, "Failed to pull some images, but continuing with restart..." + yield False, "Failed to pull some images, but continuing with restart...", False - yield False, "Images updated, restarting services..." + yield False, "Images updated, restarting services...", False # Restart with new images using streaming output restart_success = True - async for line in self._run_compose_command_streaming( + async for message, replace_last in self._run_compose_command_streaming( ["up", "-d", "--force-recreate"], cpu_mode ): - yield False, line + yield False, message, replace_last # Check for error patterns in the output - if "error" in line.lower() or "failed" in line.lower(): + if "error" in message.lower() or "failed" in message.lower(): restart_success = False if restart_success: - yield True, "Services upgraded and restarted successfully" + yield True, "Services upgraded and restarted successfully", False else: - yield False, "Some errors occurred during service restart" + yield False, "Some errors occurred during service restart", False async def reset_services(self) -> AsyncIterator[tuple[bool, str]]: """Reset all services (stop, remove containers/volumes, clear data) and yield progress updates.""" diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 7d4a8924..64fa4da7 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -507,10 +507,8 @@ class ConfigScreen(Screen): # Save to file if self.env_manager.save_env_file(): self.notify("Configuration saved successfully!", severity="information") - # Switch to monitor screen - from .monitor import MonitorScreen - - self.app.push_screen(MonitorScreen()) + # Go back to welcome screen + self.dismiss() else: self.notify("Failed to save configuration", severity="error") diff --git a/src/tui/screens/welcome.py b/src/tui/screens/welcome.py index ea85de9e..ea313cfa 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -237,6 +237,22 @@ class WelcomeScreen(Screen): except: pass # Button might not exist + async def on_resume(self) -> None: + """Called when returning from another screen (e.g., config screen).""" + # Reload environment variables + load_dotenv(override=True) + + # Update OAuth config state + self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool( + os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID") + ) + + # Re-detect service state + self._detect_services_sync() + + # Refresh the welcome content and buttons + await self._refresh_welcome_content() + def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "basic-setup-btn": diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 015861f0..0c762d45 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -6,7 +6,7 @@ from typing import Callable, Optional, AsyncIterator from rich.text import Text from textual.app import ComposeResult -from textual.containers import Container, ScrollableContainer +from textual.containers import Container from textual.screen import ModalScreen from textual.widgets import Button, Static, Label, TextArea @@ -38,23 +38,13 @@ class CommandOutputModal(ModalScreen): text-style: bold; } - #output-container { - height: 1fr; - padding: 0; - margin: 0 1; - } - #command-output { - height: 100%; + height: 1fr; border: solid $accent; - margin: 1 0; + margin: 1; background: $surface-darken-1; } - #command-output > .text-area--content { - padding: 1 2; - } - #button-row { width: 100%; height: auto; @@ -84,27 +74,27 @@ class CommandOutputModal(ModalScreen): Args: title: Title of the modal dialog - command_generator: Async generator that yields (is_complete, message) tuples + command_generator: Async generator that yields (is_complete, message) or (is_complete, message, replace_last) tuples on_complete: Optional callback to run when command completes """ super().__init__() self.title_text = title self.command_generator = command_generator self.on_complete = on_complete - self._output_text: str = "" + self._output_lines: list[str] = [] + self._layer_line_map: dict[str, int] = {} # Maps layer ID to line index self._status_task: Optional[asyncio.Task] = None def compose(self) -> ComposeResult: """Create the modal dialog layout.""" with Container(id="dialog"): yield Label(self.title_text, id="title") - with ScrollableContainer(id="output-container"): - yield TextArea( - text="", - read_only=True, - show_line_numbers=False, - id="command-output", - ) + yield TextArea( + text="", + read_only=True, + show_line_numbers=False, + id="command-output", + ) with Container(id="button-row"): yield Button("Copy Output", variant="default", id="copy-btn") yield Button( @@ -116,11 +106,6 @@ class CommandOutputModal(ModalScreen): """Start the command when the modal is mounted.""" # Start the command but don't store the worker self.run_worker(self._run_command(), exclusive=False) - # Focus the output so users can select text immediately - try: - self.query_one("#command-output", TextArea).focus() - except Exception: - pass def on_unmount(self) -> None: """Cancel any pending timers when modal closes.""" @@ -138,19 +123,28 @@ class CommandOutputModal(ModalScreen): async def _run_command(self) -> None: """Run the command and update the output in real-time.""" output = self.query_one("#command-output", TextArea) - container = self.query_one("#output-container", ScrollableContainer) try: - async for is_complete, message in self.command_generator: - self._append_output(message) - output.text = self._output_text - container.scroll_end(animate=False) + async for result in self.command_generator: + # Handle both (is_complete, message) and (is_complete, message, replace_last) tuples + if len(result) == 2: + is_complete, message = result + replace_last = False + else: + is_complete, message, replace_last = result + + self._update_output(message, replace_last) + output.text = "\n".join(self._output_lines) + + # Move cursor to end to trigger scroll + output.move_cursor((len(self._output_lines), 0)) # If command is complete, update UI if is_complete: - self._append_output("Command completed successfully") - output.text = self._output_text - container.scroll_end(animate=False) + self._update_output("Command completed successfully", False) + output.text = "\n".join(self._output_lines) + output.move_cursor((len(self._output_lines), 0)) + # Call the completion callback if provided if self.on_complete: await asyncio.sleep(0.5) # Small delay for better UX @@ -162,30 +156,62 @@ class CommandOutputModal(ModalScreen): self.call_after_refresh(_invoke_callback) except Exception as e: - self._append_output(f"Error: {e}") - output.text = self._output_text - container.scroll_end(animate=False) + self._update_output(f"Error: {e}", False) + output.text = "\n".join(self._output_lines) + output.move_cursor((len(self._output_lines), 0)) finally: # Enable the close button and focus it close_btn = self.query_one("#close-btn", Button) close_btn.disabled = False close_btn.focus() - def _append_output(self, message: str) -> None: - """Append a message to the output buffer.""" + def _update_output(self, message: str, replace_last: bool = False) -> None: + """Update the output buffer by appending or replacing the last line. + + Args: + message: The message to add or use as replacement + replace_last: If True, replace the last line (or layer-specific line); if False, append new line + """ if message is None: return message = message.rstrip("\n") if not message: return - if self._output_text: - self._output_text += "\n" + message + + # Always check if this is a layer update (regardless of replace_last flag) + parts = message.split(None, 1) + if parts: + potential_layer_id = parts[0] + + # Check if this looks like a layer ID (hex string, 12 chars for Docker layers) + if len(potential_layer_id) == 12 and all(c in '0123456789abcdefABCDEF' for c in potential_layer_id): + # This is a layer message + if potential_layer_id in self._layer_line_map: + # Update the existing line for this layer + line_idx = self._layer_line_map[potential_layer_id] + if 0 <= line_idx < len(self._output_lines): + self._output_lines[line_idx] = message + return + else: + # New layer, add it and track the line index + self._layer_line_map[potential_layer_id] = len(self._output_lines) + self._output_lines.append(message) + return + + # Not a layer message, handle normally + if replace_last: + # Fallback: just replace the last line + if self._output_lines: + self._output_lines[-1] = message + else: + self._output_lines.append(message) else: - self._output_text = message + # Append as a new line + self._output_lines.append(message) def copy_to_clipboard(self) -> None: """Copy the modal output to the clipboard.""" - if not self._output_text: + if not self._output_lines: message = "No output to copy yet" self.notify(message, severity="warning") status = self.query_one("#copy-status", Static) @@ -193,7 +219,8 @@ class CommandOutputModal(ModalScreen): self._schedule_status_clear(status) return - success, message = copy_text_to_clipboard(self._output_text) + output_text = "\n".join(self._output_lines) + success, message = copy_text_to_clipboard(output_text) self.notify(message, severity="information" if success else "error") status = self.query_one("#copy-status", Static) style = "bold green" if success else "bold red" From c8fe21f33a5f27438ef65c515967d6b5379e9421 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 15:12:28 -0400 Subject: [PATCH 02/14] styling fixes --- src/tui/main.py | 140 +++++++++++++++++++++---------- src/tui/screens/welcome.py | 2 +- src/tui/widgets/command_modal.py | 98 ++++++++++++++++++++-- 3 files changed, 189 insertions(+), 51 deletions(-) diff --git a/src/tui/main.py b/src/tui/main.py index beee4497..a63f9e8d 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -32,34 +32,35 @@ class OpenRAGTUI(App): CSS = """ Screen { - background: #0f172a; + background: #27272a; } - + #main-container { height: 100%; padding: 1; } - + #welcome-container { align: center middle; width: 100%; height: 100%; } - + #welcome-text { text-align: center; margin-bottom: 2; } - + .button-row { align: center middle; height: auto; margin: 1 0; } - + .button-row Button { margin: 0 1; min-width: 20; + border: solid #3f3f46; } #config-header { @@ -182,80 +183,131 @@ class OpenRAGTUI(App): padding: 1; } - /* Frontend-inspired color scheme */ + /* Modern dark theme with pink accents */ Static { - color: #f1f5f9; + color: #fafafa; } - Button.success { - background: #4ade80; - color: #000; + Button, + Button.-default, + Button.-primary, + Button.-success, + Button.-warning, + Button.-error { + background: #27272a !important; + color: #fafafa !important; + border: round #52525b !important; + text-style: none !important; + tint: transparent 0% !important; } - Button.error { - background: #ef4444; - color: #fff; + Button > *, + Button.-default > *, + Button.-primary > *, + Button.-success > *, + Button.-warning > *, + Button.-error > * { + background: transparent !important; + color: #fafafa !important; + text-style: none !important; } - Button.warning { - background: #eab308; - color: #000; + Button:hover, + Button.-default:hover, + Button.-primary:hover, + Button.-success:hover, + Button.-warning:hover, + Button.-error:hover { + background: #27272a !important; + color: #fafafa !important; + border: round #52525b !important; } - Button.primary { - background: #2563eb; - color: #fff; - } - - Button.default { - background: #475569; - color: #f1f5f9; - border: solid #64748b; + Button:focus, + Button:focus-within, + Button.-active, + Button.-default:focus, + Button.-default:focus-within, + Button.-default.-active, + Button.-primary:focus, + Button.-primary:focus-within, + Button.-primary.-active, + Button.-success:focus, + Button.-success:focus-within, + Button.-success.-active, + Button.-warning:focus, + Button.-warning:focus-within, + Button.-warning.-active, + Button.-error:focus, + Button.-error:focus-within, + Button.-error.-active { + background: #27272a !important; + color: #fafafa !important; + border: round #ec4899 !important; } DataTable { - background: #1e293b; - color: #f1f5f9; + background: #27272a; + color: #fafafa; } DataTable > .datatable--header { - background: #334155; - color: #f1f5f9; + background: #3f3f46; + color: #fafafa; } DataTable > .datatable--cursor { - background: #475569; + background: #52525b; } Input { - background: #334155; - color: #f1f5f9; - border: solid #64748b; + background: #18181b; + color: #fafafa; + border: solid #3f3f46; + } + + Input:focus { + border: solid #ec4899; } Label { - color: #f1f5f9; + color: #fafafa; + } + + Header { + background: #27272a; + color: #fafafa; } Footer { - background: #334155; - color: #f1f5f9; + background: #27272a; + color: #a1a1aa; } #runtime-status { - background: #1e293b; - border: solid #64748b; - color: #f1f5f9; + background: #27272a; + border: solid #3f3f46; + color: #fafafa; } #system-info { - background: #1e293b; - border: solid #64748b; - color: #f1f5f9; + background: #27272a; + border: solid #3f3f46; + color: #fafafa; } #services-table, #images-table { - background: #1e293b; + background: #27272a; + } + + * { + scrollbar-background: #27272a; + scrollbar-background-hover: #3f3f46; + scrollbar-background-active: #3f3f46; + scrollbar-color: #52525b; + scrollbar-color-hover: #71717a; + scrollbar-color-active: #71717a; + scrollbar-corner-color: #27272a; } """ diff --git a/src/tui/screens/welcome.py b/src/tui/screens/welcome.py index ea313cfa..c2f21247 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -118,7 +118,7 @@ class WelcomeScreen(Screen): """ welcome_text.append(ascii_art, style="bold white") welcome_text.append("Terminal User Interface for OpenRAG\n", style="dim") - welcome_text.append(f"v{__version__}\n\n", style="dim cyan") + welcome_text.append(f"v{__version__}\n\n", style="white") # Check if all services are running all_services_running = self.services_running and self.docling_running diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 0c762d45..7ed8b30e 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -24,14 +24,14 @@ class CommandOutputModal(ModalScreen): #dialog { width: 90%; height: 90%; - border: thick $primary; - background: $surface; + border: solid #3f3f46; + background: #27272a; padding: 0; } #title { - background: $primary; - color: $text; + background: #3f3f46; + color: #fafafa; padding: 1 2; text-align: center; width: 100%; @@ -40,9 +40,10 @@ class CommandOutputModal(ModalScreen): #command-output { height: 1fr; - border: solid $accent; + border: solid #3f3f46; margin: 1; - background: $surface-darken-1; + background: #18181b; + color: #fafafa; } #button-row { @@ -56,11 +57,96 @@ class CommandOutputModal(ModalScreen): #button-row Button { margin: 0 1; min-width: 16; + background: #27272a; + color: #fafafa; + border: round #52525b; + text-style: none; + tint: transparent 0%; + } + + #button-row Button > Static { + background: transparent !important; + color: #fafafa !important; + text-style: none; + } + + #button-row Button > * { + background: transparent !important; + color: #fafafa !important; + } + + #button-row Button:hover { + background: #27272a !important; + color: #fafafa !important; + border: round #52525b; + tint: transparent 0%; + text-style: none; + } + + #button-row Button:hover > Static { + background: transparent !important; + color: #fafafa !important; + text-style: none; + } + + #button-row Button:hover > * { + background: transparent !important; + color: #fafafa !important; + } + + #button-row Button:focus { + background: #27272a !important; + color: #fafafa !important; + border: round #ec4899; + tint: transparent 0%; + text-style: none; + } + + #button-row Button:focus > Static { + background: transparent !important; + color: #fafafa !important; + text-style: none; + } + + #button-row Button:focus > * { + background: transparent !important; + color: #fafafa !important; + } + + #button-row Button.-active { + background: #27272a !important; + color: #fafafa !important; + border: round #ec4899; + tint: transparent 0%; + text-style: none; + } + + #button-row Button.-active > Static { + background: transparent !important; + color: #fafafa !important; + text-style: none; + } + + #button-row Button.-active > * { + background: transparent !important; + color: #fafafa !important; + } + + #button-row Button:disabled { + background: #27272a; + color: #52525b; + border: round #3f3f46; + } + + #button-row Button:disabled > Static { + background: transparent; + color: #52525b; } #copy-status { text-align: center; margin-bottom: 1; + color: #a1a1aa; } """ From 5f70e6042ab53f3fe24aa5c33e4559c26b95ccee Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 15:18:05 -0400 Subject: [PATCH 03/14] fix service detection --- src/tui/screens/welcome.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/screens/welcome.py b/src/tui/screens/welcome.py index c2f21247..b0cc62c9 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -237,7 +237,7 @@ class WelcomeScreen(Screen): except: pass # Button might not exist - async def on_resume(self) -> None: + async def on_screen_resume(self) -> None: """Called when returning from another screen (e.g., config screen).""" # Reload environment variables load_dotenv(override=True) From b13f4c761e2f51a20a78d1bcec944fa1d2599eb1 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 15:32:57 -0400 Subject: [PATCH 04/14] modal minigame --- src/tui/widgets/command_modal.py | 49 ++++++++++- src/tui/widgets/waves.py | 146 +++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/tui/widgets/waves.py diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 7ed8b30e..379c946d 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -6,21 +6,36 @@ from typing import Callable, Optional, AsyncIterator from rich.text import Text from textual.app import ComposeResult -from textual.containers import Container +from textual.containers import Container, Horizontal from textual.screen import ModalScreen -from textual.widgets import Button, Static, Label, TextArea +from textual.widgets import Button, Static, Label, TextArea, Footer from ..utils.clipboard import copy_text_to_clipboard +from .waves import Waves class CommandOutputModal(ModalScreen): """Modal dialog for displaying command output in real-time.""" + BINDINGS = [ + ("w,+", "add_wave", "Add"), + ("r,-", "remove_wave", "Remove"), + ("p", "pause_waves", "Pause"), + ("up", "speed_up", "Faster"), + ("down", "speed_down", "Slower"), + ] + DEFAULT_CSS = """ CommandOutputModal { align: center middle; } + #waves-background { + width: 100%; + height: 100%; + layer: background; + } + #dialog { width: 90%; height: 90%; @@ -173,6 +188,7 @@ class CommandOutputModal(ModalScreen): def compose(self) -> ComposeResult: """Create the modal dialog layout.""" + yield Waves(id="waves-background") with Container(id="dialog"): yield Label(self.title_text, id="title") yield TextArea( @@ -187,6 +203,7 @@ class CommandOutputModal(ModalScreen): "Close", variant="primary", id="close-btn", disabled=True ) yield Static("", id="copy-status") + yield Footer() def on_mount(self) -> None: """Start the command when the modal is mounted.""" @@ -206,6 +223,34 @@ class CommandOutputModal(ModalScreen): elif event.button.id == "copy-btn": self.copy_to_clipboard() + def action_add_wave(self) -> None: + """Add a wave to the animation.""" + waves = self.query_one("#waves-background", Waves) + waves._add_wavelet() + + def action_remove_wave(self) -> None: + """Remove a wave from the animation.""" + waves = self.query_one("#waves-background", Waves) + if waves.wavelets: + waves.wavelets.pop() + + def action_pause_waves(self) -> None: + """Pause/unpause the wave animation.""" + waves = self.query_one("#waves-background", Waves) + waves.paused = not waves.paused + + def action_speed_up(self) -> None: + """Increase wave speed.""" + waves = self.query_one("#waves-background", Waves) + for w in waves.wavelets: + w.speed = min(2.0, w.speed * 1.2) + + def action_speed_down(self) -> None: + """Decrease wave speed.""" + waves = self.query_one("#waves-background", Waves) + for w in waves.wavelets: + w.speed = max(0.1, w.speed * 0.8) + async def _run_command(self) -> None: """Run the command and update the output in real-time.""" output = self.query_one("#command-output", TextArea) diff --git a/src/tui/widgets/waves.py b/src/tui/widgets/waves.py new file mode 100644 index 00000000..5e3d7d8f --- /dev/null +++ b/src/tui/widgets/waves.py @@ -0,0 +1,146 @@ +"""Waves animation widget for command modals.""" + +import math +import random +from dataclasses import dataclass +from typing import List + +from textual import events +from textual.reactive import reactive +from textual.widgets import Static + + +@dataclass +class Wavelet: + x: float + lane: int # 0 or 1 + speed: float # chars/frame + phase: float # for subtle vertical bob / color cycle + hue: int # index in palette + + +class Waves(Static): + """Animated waves widget that displays moving wavelets around the border.""" + + can_focus = False + fps = 24 + paused = reactive(False) + show_help = reactive(False) + + def on_mount(self): + self.palette = ["#93c5fd", "#60a5fa", "#38bdf8", "#a78bfa", "#f472b6"] + self.wavelets: List[Wavelet] = [] + # Start with a few wavelets + for _ in range(3): + self._add_wavelet() + self.set_interval(1 / self.fps, self._tick) + + def _offset_for_lane(self, lane: int, width: int, height: int) -> int: + max_offset = max(1, min(width, height) // 2 - 1) + return min(1 + lane, max_offset) + + def _build_path( + self, width: int, height: int, offset: int + ) -> List[tuple[int, int]]: + left = offset + right = max(offset, width - offset - 1) + top = offset + bottom = max(offset, height - offset - 1) + if right < left or bottom < top: + return [(max(0, left), max(0, top))] + + path: List[tuple[int, int]] = [] + # Top edge + for x in range(left, right + 1): + path.append((x, top)) + # Right edge (excluding corners already added) + for y in range(top + 1, bottom): + path.append((right, y)) + # Bottom edge (if distinct from top) + if bottom != top: + for x in range(right, left - 1, -1): + path.append((x, bottom)) + # Left edge (excluding corners) + if right != left: + for y in range(bottom - 1, top, -1): + path.append((left, y)) + return path or [(left, top)] + + def _path_for_lane( + self, width: int, height: int, lane: int + ) -> List[tuple[int, int]]: + offset = self._offset_for_lane(lane, width, height) + path = self._build_path(width, height, offset) + if not path and offset > 1: + offset = 1 + path = self._build_path(width, height, offset) + return path + + def _add_wavelet(self): + w = max(10, self.size.width) + self.wavelets.append( + Wavelet( + x=0, + lane=random.choice([0, 1]), + speed=0.25 + random.random() * 0.35, # slow & smooth + phase=random.random() * math.tau, + hue=random.randrange(len(self.palette)), + ) + ) + # Initialize position once we know the current perimeter + h = max(6, self.size.height) + path = self._path_for_lane(w, h, self.wavelets[-1].lane) + if path: + self.wavelets[-1].x = random.uniform(0, len(path)) + + def set_throughput(self, bytes_per_sec: float): + """Modulate wavelet speed based on download throughput.""" + boost = min(1.8, 1.0 + math.log10(bytes_per_sec + 1) / 6.0) + for w in self.wavelets: + w.speed = min(1.2, w.speed * 0.7 + (0.25 * boost)) + + def _tick(self): + if self.paused: + self.refresh() + return + width = max(10, self.size.width) + height = max(6, self.size.height) + for w in self.wavelets: + path = self._path_for_lane(width, height, w.lane) + if not path: + continue + perimeter = len(path) + if perimeter <= 0: + continue + w.x %= perimeter + new_pos = w.x + w.speed + wrapped = new_pos >= perimeter + w.x = new_pos % perimeter + if wrapped: + # Tiny color/phase change on wrap + w.hue = (w.hue + 1) % len(self.palette) + w.phase = random.random() * math.tau + self.refresh() + + def render(self) -> str: + W = max(10, self.size.width) + H = max(6, self.size.height) + buf = [[" "] * W for _ in range(H)] + + # Draw wavelets moving around the border + for wv in self.wavelets: + path = self._path_for_lane(W, H, wv.lane) + if not path: + continue + perimeter = len(path) + if perimeter <= 0: + continue + idx = int(wv.x) % perimeter + x, y = path[idx] + if 0 <= x < W and 0 <= y < H: + col = self.palette[wv.hue] + buf[y][x] = f"[{col}]≈[/]" + + # No border - just wavelets on empty background + + return "\n".join("".join(r) for r in buf) From 4ff3b8e57c7d4fb7dacd9152abc19b394cb0c277 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 15:33:08 -0400 Subject: [PATCH 05/14] better keybindings --- src/tui/widgets/command_modal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 379c946d..58e1e5af 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -21,8 +21,8 @@ class CommandOutputModal(ModalScreen): ("w,+", "add_wave", "Add"), ("r,-", "remove_wave", "Remove"), ("p", "pause_waves", "Pause"), - ("up", "speed_up", "Faster"), - ("down", "speed_down", "Slower"), + ("f", "speed_up", "Faster"), + ("s", "speed_down", "Slower"), ] DEFAULT_CSS = """ From bbb63d5dce882c2329ead92996fb43011dfb959a Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 15:40:24 -0400 Subject: [PATCH 06/14] os password validator --- src/tui/screens/config.py | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 64fa4da7..42694f82 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -1,5 +1,6 @@ """Configuration screen for OpenRAG TUI.""" +import re from textual.app import ComposeResult from textual.containers import Container, Vertical, Horizontal, ScrollableContainer from textual.screen import Screen @@ -50,6 +51,40 @@ class DocumentsPathValidator(Validator): return self.failure(error_msg) +class PasswordValidator(Validator): + """Validator for OpenSearch admin password.""" + + def validate(self, value: str) -> ValidationResult: + # Allow empty value (will be auto-generated) + if not value: + return self.success() + + # Minimum length: 8 characters + if len(value) < 8: + return self.failure("Password must be at least 8 characters long") + + # Check for required character types + has_uppercase = bool(re.search(r"[A-Z]", value)) + has_lowercase = bool(re.search(r"[a-z]", value)) + has_digit = bool(re.search(r"[0-9]", value)) + has_special = bool(re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"\\|,.<>/?]", value)) + + missing = [] + if not has_uppercase: + missing.append("uppercase letter") + if not has_lowercase: + missing.append("lowercase letter") + if not has_digit: + missing.append("digit") + if not has_special: + missing.append("special character") + + if missing: + return self.failure(f"Password must contain: {', '.join(missing)}") + + return self.success() + + class ConfigScreen(Screen): """Configuration screen for environment setup.""" @@ -118,9 +153,14 @@ class ConfigScreen(Screen): value=current_value, password=True, id="input-opensearch_password", + validators=[PasswordValidator()], ) yield input_widget self.inputs["opensearch_password"] = input_widget + yield Static( + "Min 8 chars with uppercase, lowercase, digit, and special character", + classes="helper-text", + ) yield Static(" ") # Langflow Admin Username @@ -488,6 +528,22 @@ class ConfigScreen(Screen): def action_save(self) -> None: """Save the configuration.""" + # First, check Textual input validators + validation_errors = [] + for field_name, input_widget in self.inputs.items(): + if hasattr(input_widget, "validate") and input_widget.value: + result = input_widget.validate(input_widget.value) + if result and not result.is_valid: + for failure in result.failures: + validation_errors.append(f"{field_name}: {failure.description}") + + if validation_errors: + self.notify( + f"Validation failed:\n" + "\n".join(validation_errors[:3]), + severity="error", + ) + return + # Update config from input fields for field_name, input_widget in self.inputs.items(): setattr(self.env_manager.config, field_name, input_widget.value) From 037bc31800136ea5afbd35179666d3c38062e247 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 16:27:26 -0400 Subject: [PATCH 07/14] add langflow auto_login env to openrag-backend --- docker-compose-cpu.yml | 1 + docker-compose.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/docker-compose-cpu.yml b/docker-compose-cpu.yml index 40507c94..f7c15ab3 100644 --- a/docker-compose-cpu.yml +++ b/docker-compose-cpu.yml @@ -50,6 +50,7 @@ services: - OPENSEARCH_HOST=opensearch - LANGFLOW_URL=http://langflow:7860 - LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL} + - LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN} - LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY} - LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER} - LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD} diff --git a/docker-compose.yml b/docker-compose.yml index 4a68d210..35fc5ce4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,7 @@ services: - OPENSEARCH_HOST=opensearch - LANGFLOW_URL=http://langflow:7860 - LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL} + - LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN} - LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER} - LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD} - LANGFLOW_CHAT_FLOW_ID=${LANGFLOW_CHAT_FLOW_ID} From 8e1fd7a7feb13f6450d1e0c3ce08a6dbb2b29289 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 16:47:55 -0400 Subject: [PATCH 08/14] tui and backend: handle langflow optional password config --- src/config/settings.py | 16 +++- src/tui/main.py | 48 +++++++++++ src/tui/managers/env_manager.py | 16 ++-- src/tui/screens/config.py | 144 +++++++++++++++++++++---------- src/tui/widgets/command_modal.py | 3 + 5 files changed, 172 insertions(+), 55 deletions(-) diff --git a/src/config/settings.py b/src/config/settings.py index 598ccfb2..0672ad68 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -43,6 +43,7 @@ if _legacy_flow_id and not os.getenv("LANGFLOW_CHAT_FLOW_ID"): # Langflow superuser credentials for API key generation +LANGFLOW_AUTO_LOGIN = os.getenv("LANGFLOW_AUTO_LOGIN", "False").lower() in ("true", "1", "yes") LANGFLOW_SUPERUSER = os.getenv("LANGFLOW_SUPERUSER") LANGFLOW_SUPERUSER_PASSWORD = os.getenv("LANGFLOW_SUPERUSER_PASSWORD") # Allow explicit key via environment; generation will be skipped if set @@ -179,7 +180,16 @@ async def generate_langflow_api_key(modify: bool = False): ) LANGFLOW_KEY = None # Clear invalid key - if not LANGFLOW_SUPERUSER or not LANGFLOW_SUPERUSER_PASSWORD: + # Use default langflow/langflow credentials if auto-login is enabled and credentials not set + username = LANGFLOW_SUPERUSER + password = LANGFLOW_SUPERUSER_PASSWORD + + if LANGFLOW_AUTO_LOGIN and (not username or not password): + logger.info("LANGFLOW_AUTO_LOGIN is enabled, using default langflow/langflow credentials") + username = username or "langflow" + password = password or "langflow" + + if not username or not password: logger.warning( "LANGFLOW_SUPERUSER and LANGFLOW_SUPERUSER_PASSWORD not set, skipping API key generation" ) @@ -197,8 +207,8 @@ async def generate_langflow_api_key(modify: bool = False): f"{LANGFLOW_URL}/api/v1/login", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ - "username": LANGFLOW_SUPERUSER, - "password": LANGFLOW_SUPERUSER_PASSWORD, + "username": username, + "password": password, }, timeout=10, ) diff --git a/src/tui/main.py b/src/tui/main.py index a63f9e8d..51418786 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -107,6 +107,41 @@ class OpenRAGTUI(App): margin: 0 0 1 1; } + /* Password field label rows */ + #config-form Horizontal { + height: auto; + align: left middle; + margin-bottom: 0; + } + + #config-form Horizontal Label { + width: auto; + margin-right: 1; + } + + /* Password input rows */ + #opensearch-password-row, + #langflow-password-row { + width: 100%; + height: auto; + align: left middle; + } + + #opensearch-password-row Input, + #langflow-password-row Input { + width: 1fr; + } + + /* Password toggle buttons */ + #toggle-opensearch-password, + #toggle-langflow-password { + min-width: 8; + width: 8; + height: 3; + padding: 0 1; + margin-left: 1; + } + /* Docs path actions row */ #services-content { @@ -274,6 +309,19 @@ class OpenRAGTUI(App): color: #fafafa; } + Checkbox { + background: transparent; + color: #fafafa; + border: none; + padding: 0; + margin-left: 2; + } + + Checkbox > Static { + background: transparent; + color: #fafafa; + } + Header { background: #27272a; color: #fafafa; diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index 9510fb70..f1420e42 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -149,8 +149,17 @@ class EnvManager: if not self.config.langflow_secret_key: self.config.langflow_secret_key = self.generate_langflow_secret_key() + # Configure autologin based on whether password is set if not self.config.langflow_superuser_password: - self.config.langflow_superuser_password = self.generate_secure_password() + # If no password is set, enable autologin mode + self.config.langflow_auto_login = "True" + self.config.langflow_new_user_is_active = "True" + self.config.langflow_enable_superuser_cli = "True" + else: + # If password is set, disable autologin mode + self.config.langflow_auto_login = "False" + self.config.langflow_new_user_is_active = "False" + self.config.langflow_enable_superuser_cli = "False" def validate_config(self, mode: str = "full") -> bool: """ @@ -183,10 +192,7 @@ class EnvManager: # Langflow secret key is auto-generated; no user input required - if not validate_non_empty(self.config.langflow_superuser_password): - self.config.validation_errors["langflow_superuser_password"] = ( - "Langflow superuser password is required" - ) + # Langflow password is now optional - if not provided, autologin mode will be enabled if mode == "full": # Validate OAuth settings if provided diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 42694f82..4a21edea 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -13,6 +13,7 @@ from textual.widgets import ( Label, TabbedContent, TabPane, + Checkbox, ) from textual.validation import ValidationResult, Validator from rich.text import Text @@ -147,20 +148,22 @@ class ConfigScreen(Screen): # OpenSearch Admin Password yield Label("OpenSearch Admin Password *") - current_value = getattr(self.env_manager.config, "opensearch_password", "") - input_widget = Input( - placeholder="Auto-generated secure password", - value=current_value, - password=True, - id="input-opensearch_password", - validators=[PasswordValidator()], - ) - yield input_widget - self.inputs["opensearch_password"] = input_widget yield Static( "Min 8 chars with uppercase, lowercase, digit, and special character", classes="helper-text", ) + current_value = getattr(self.env_manager.config, "opensearch_password", "") + with Horizontal(id="opensearch-password-row"): + input_widget = Input( + placeholder="Auto-generated secure password", + value=current_value, + password=True, + id="input-opensearch_password", + validators=[PasswordValidator()], + ) + yield input_widget + self.inputs["opensearch_password"] = input_widget + yield Button("👁", id="toggle-opensearch-password", variant="default") yield Static(" ") # Langflow Admin Username @@ -174,18 +177,22 @@ class ConfigScreen(Screen): yield Static(" ") # Langflow Admin Password - yield Label("Langflow Admin Password *") + with Horizontal(): + yield Label("Langflow Admin Password (optional)") + yield Checkbox("Generate password", id="generate-langflow-password") current_value = getattr( self.env_manager.config, "langflow_superuser_password", "" ) - input_widget = Input( - placeholder="Auto-generated secure password", - value=current_value, - password=True, - id="input-langflow_superuser_password", - ) - yield input_widget - self.inputs["langflow_superuser_password"] = input_widget + with Horizontal(id="langflow-password-row"): + input_widget = Input( + placeholder="Langflow password", + value=current_value, + password=True, + id="input-langflow_superuser_password", + ) + yield input_widget + self.inputs["langflow_superuser_password"] = input_widget + yield Button("👁", id="toggle-langflow-password", variant="default") yield Static(" ") yield Static(" ") @@ -201,15 +208,17 @@ class ConfigScreen(Screen): classes="helper-text", ) current_value = getattr(self.env_manager.config, "openai_api_key", "") - input_widget = Input( - placeholder="sk-...", - value=current_value, - password=True, - validators=[OpenAIKeyValidator()], - id="input-openai_api_key", - ) - yield input_widget - self.inputs["openai_api_key"] = input_widget + with Horizontal(id="openai-key-row"): + input_widget = Input( + placeholder="sk-...", + value=current_value, + password=True, + validators=[OpenAIKeyValidator()], + id="input-openai_api_key", + ) + yield input_widget + self.inputs["openai_api_key"] = input_widget + yield Button("Show", id="toggle-openai-key", variant="default") yield Static(" ") # Add OAuth fields only in full mode @@ -252,14 +261,16 @@ class ConfigScreen(Screen): current_value = getattr( self.env_manager.config, "google_oauth_client_secret", "" ) - input_widget = Input( - placeholder="", - value=current_value, - password=True, - id="input-google_oauth_client_secret", - ) - yield input_widget - self.inputs["google_oauth_client_secret"] = input_widget + with Horizontal(id="google-secret-row"): + input_widget = Input( + placeholder="", + value=current_value, + password=True, + id="input-google_oauth_client_secret", + ) + yield input_widget + self.inputs["google_oauth_client_secret"] = input_widget + yield Button("Show", id="toggle-google-secret", variant="default") yield Static(" ") # Microsoft Graph Client ID @@ -300,14 +311,16 @@ class ConfigScreen(Screen): current_value = getattr( self.env_manager.config, "microsoft_graph_oauth_client_secret", "" ) - input_widget = Input( - placeholder="", - value=current_value, - password=True, - id="input-microsoft_graph_oauth_client_secret", - ) - yield input_widget - self.inputs["microsoft_graph_oauth_client_secret"] = input_widget + with Horizontal(id="microsoft-secret-row"): + input_widget = Input( + placeholder="", + value=current_value, + password=True, + id="input-microsoft_graph_oauth_client_secret", + ) + yield input_widget + self.inputs["microsoft_graph_oauth_client_secret"] = input_widget + yield Button("Show", id="toggle-microsoft-secret", variant="default") yield Static(" ") # AWS Access Key ID @@ -503,6 +516,22 @@ class ConfigScreen(Screen): except Exception: pass + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + """Handle checkbox changes.""" + if event.checkbox.id == "generate-langflow-password": + langflow_password_input = self.inputs.get("langflow_superuser_password") + if event.value: + # Generate password when checked + password = self.env_manager.generate_secure_password() + if langflow_password_input: + langflow_password_input.value = password + self.notify("Generated Langflow password", severity="information") + else: + # Clear password when unchecked (enable autologin) + if langflow_password_input: + langflow_password_input.value = "" + self.notify("Cleared Langflow password - autologin enabled", severity="information") + def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "generate-btn": @@ -513,18 +542,39 @@ class ConfigScreen(Screen): self.action_back() elif event.button.id == "pick-docs-btn": self.action_pick_documents_path() + elif event.button.id == "toggle-opensearch-password": + # Toggle OpenSearch password visibility + input_widget = self.inputs.get("opensearch_password") + if input_widget: + input_widget.password = not input_widget.password + event.button.label = "🙈" if not input_widget.password else "👁" + elif event.button.id == "toggle-langflow-password": + # Toggle Langflow password visibility + input_widget = self.inputs.get("langflow_superuser_password") + if input_widget: + input_widget.password = not input_widget.password + event.button.label = "🙈" if not input_widget.password else "👁" def action_generate(self) -> None: """Generate secure passwords for admin accounts.""" - self.env_manager.setup_secure_defaults() + # Only generate OpenSearch password - leave Langflow empty for autologin mode + if not self.env_manager.config.opensearch_password: + self.env_manager.config.opensearch_password = self.env_manager.generate_secure_password() + + # Update secret keys + if not self.env_manager.config.langflow_secret_key: + self.env_manager.config.langflow_secret_key = self.env_manager.generate_langflow_secret_key() + + if not self.env_manager.config.session_secret: + self.env_manager.config.session_secret = self.env_manager.generate_session_secret() # Update input fields with generated values for field_name, input_widget in self.inputs.items(): - if field_name in ["opensearch_password", "langflow_superuser_password"]: + if field_name == "opensearch_password": new_value = getattr(self.env_manager.config, field_name) input_widget.value = new_value - self.notify("Generated secure passwords", severity="information") + self.notify("Generated secure password for OpenSearch", severity="information") def action_save(self) -> None: """Save the configuration.""" diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 58e1e5af..a5013031 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -28,12 +28,14 @@ class CommandOutputModal(ModalScreen): DEFAULT_CSS = """ CommandOutputModal { align: center middle; + overflow: hidden; } #waves-background { width: 100%; height: 100%; layer: background; + overflow: hidden; } #dialog { @@ -42,6 +44,7 @@ class CommandOutputModal(ModalScreen): border: solid #3f3f46; background: #27272a; padding: 0; + overflow: hidden; } #title { From 62ee661b15a556ddfac24d3e6347e6923ba03914 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 16:59:09 -0400 Subject: [PATCH 09/14] stop should not down --- src/tui/managers/container_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index 27b41d10..41dd7178 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -603,7 +603,7 @@ class ContainerManager: """Stop all services and yield progress updates.""" yield False, "Stopping OpenRAG services..." - success, stdout, stderr = await self._run_compose_command(["down"]) + success, stdout, stderr = await self._run_compose_command(["stop"]) if success: yield True, "Services stopped successfully" From 326cdded7d71f32556400f5c83c7bf61100160f4 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 21:46:38 -0400 Subject: [PATCH 10/14] only show langflow user if password is set --- src/tui/managers/env_manager.py | 10 +++-- src/tui/screens/config.py | 76 ++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index f1420e42..8ee86627 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -255,10 +255,12 @@ class EnvManager: # Core settings f.write("# Core settings\n") f.write(f"LANGFLOW_SECRET_KEY={self._quote_env_value(self.config.langflow_secret_key)}\n") - f.write(f"LANGFLOW_SUPERUSER={self._quote_env_value(self.config.langflow_superuser)}\n") - f.write( - f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n" - ) + # Only write LANGFLOW_SUPERUSER and password if password is set + if self.config.langflow_superuser_password: + f.write(f"LANGFLOW_SUPERUSER={self._quote_env_value(self.config.langflow_superuser)}\n") + f.write( + f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n" + ) f.write(f"LANGFLOW_CHAT_FLOW_ID={self._quote_env_value(self.config.langflow_chat_flow_id)}\n") f.write( f"LANGFLOW_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_ingest_flow_id)}\n" diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 4a21edea..c60f4d97 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -166,16 +166,6 @@ class ConfigScreen(Screen): yield Button("👁", id="toggle-opensearch-password", variant="default") yield Static(" ") - # Langflow Admin Username - yield Label("Langflow Admin Username *") - current_value = getattr(self.env_manager.config, "langflow_superuser", "") - input_widget = Input( - placeholder="admin", value=current_value, id="input-langflow_superuser" - ) - yield input_widget - self.inputs["langflow_superuser"] = input_widget - yield Static(" ") - # Langflow Admin Password with Horizontal(): yield Label("Langflow Admin Password (optional)") @@ -194,6 +184,19 @@ class ConfigScreen(Screen): self.inputs["langflow_superuser_password"] = input_widget yield Button("👁", id="toggle-langflow-password", variant="default") yield Static(" ") + + # Langflow Admin Username - only show if password is set + current_password = getattr(self.env_manager.config, "langflow_superuser_password", "") + if current_password: + yield Label("Langflow Admin Username *", id="langflow-username-label") + current_value = getattr(self.env_manager.config, "langflow_superuser", "") + input_widget = Input( + placeholder="admin", value=current_value, id="input-langflow_superuser" + ) + yield input_widget + self.inputs["langflow_superuser"] = input_widget + yield Static(" ", id="langflow-username-spacer") + yield Static(" ") # API Keys Section @@ -525,11 +528,15 @@ class ConfigScreen(Screen): password = self.env_manager.generate_secure_password() if langflow_password_input: langflow_password_input.value = password + # Show username field + self._update_langflow_username_visibility(password) self.notify("Generated Langflow password", severity="information") else: # Clear password when unchecked (enable autologin) if langflow_password_input: langflow_password_input.value = "" + # Hide username field + self._update_langflow_username_visibility("") self.notify("Cleared Langflow password - autologin enabled", severity="information") def on_button_pressed(self, event: Button.Pressed) -> None: @@ -692,5 +699,54 @@ class ConfigScreen(Screen): def on_input_changed(self, event: Input.Changed) -> None: """Handle input changes for real-time validation feedback.""" + # Handle Langflow password changes - show/hide username field + if event.input.id == "input-langflow_superuser_password": + self._update_langflow_username_visibility(event.value) # This will trigger validation display in real-time pass + + def _update_langflow_username_visibility(self, password_value: str) -> None: + """Show or hide the Langflow username field based on password presence.""" + config_form = self.query_one("#config-form") + + # Check if username field already exists + username_input = self.query("#input-langflow_superuser") + username_label = self.query("#langflow-username-label") + username_spacer = self.query("#langflow-username-spacer") + + has_password = bool(password_value and password_value.strip()) + username_exists = len(username_input) > 0 + + if has_password and not username_exists: + # Show username field - mount it after the password field + password_spacer = self.query_one("#langflow-password-row").parent.query(Static)[0] + + # Create new widgets + from textual.widgets import Label, Input, Static + label = Label("Langflow Admin Username *", id="langflow-username-label") + input_widget = Input( + placeholder="admin", + value=getattr(self.env_manager.config, "langflow_superuser", "admin"), + id="input-langflow_superuser" + ) + spacer = Static(" ", id="langflow-username-spacer") + + # Mount them after the password row's spacer + password_spacer.mount_after(label) + label.mount_after(input_widget) + input_widget.mount_after(spacer) + + self.inputs["langflow_superuser"] = input_widget + + elif not has_password and username_exists: + # Hide username field + for widget in username_input: + widget.remove() + for widget in username_label: + widget.remove() + for widget in username_spacer: + widget.remove() + + # Remove from inputs dict + if "langflow_superuser" in self.inputs: + del self.inputs["langflow_superuser"] From 97ee36cea2a8cafe4c913f003f5e4b107cb32f1d Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 21:54:28 -0400 Subject: [PATCH 11/14] fix:dynamic langflow user box --- src/tui/screens/config.py | 80 +++++++++++++++------------------------ 1 file changed, 30 insertions(+), 50 deletions(-) diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index c60f4d97..d7243297 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -185,17 +185,16 @@ class ConfigScreen(Screen): yield Button("👁", id="toggle-langflow-password", variant="default") yield Static(" ") - # Langflow Admin Username - only show if password is set + # Langflow Admin Username - conditionally displayed based on password current_password = getattr(self.env_manager.config, "langflow_superuser_password", "") - if current_password: - yield Label("Langflow Admin Username *", id="langflow-username-label") - current_value = getattr(self.env_manager.config, "langflow_superuser", "") - input_widget = Input( - placeholder="admin", value=current_value, id="input-langflow_superuser" - ) - yield input_widget - self.inputs["langflow_superuser"] = input_widget - yield Static(" ", id="langflow-username-spacer") + yield Label("Langflow Admin Username *", id="langflow-username-label") + current_value = getattr(self.env_manager.config, "langflow_superuser", "") + input_widget = Input( + placeholder="admin", value=current_value, id="input-langflow_superuser" + ) + yield input_widget + self.inputs["langflow_superuser"] = input_widget + yield Static(" ", id="langflow-username-spacer") yield Static(" ") @@ -510,6 +509,10 @@ class ConfigScreen(Screen): def on_mount(self) -> None: """Initialize the screen when mounted.""" + # Set initial visibility of username field based on password + current_password = getattr(self.env_manager.config, "langflow_superuser_password", "") + self._update_langflow_username_visibility(current_password) + # Focus the first input field try: # Find the first input field and focus it @@ -707,46 +710,23 @@ class ConfigScreen(Screen): def _update_langflow_username_visibility(self, password_value: str) -> None: """Show or hide the Langflow username field based on password presence.""" - config_form = self.query_one("#config-form") - - # Check if username field already exists - username_input = self.query("#input-langflow_superuser") - username_label = self.query("#langflow-username-label") - username_spacer = self.query("#langflow-username-spacer") - has_password = bool(password_value and password_value.strip()) - username_exists = len(username_input) > 0 - if has_password and not username_exists: - # Show username field - mount it after the password field - password_spacer = self.query_one("#langflow-password-row").parent.query(Static)[0] + # Get the widgets + try: + username_label = self.query_one("#langflow-username-label") + username_input = self.query_one("#input-langflow_superuser") + username_spacer = self.query_one("#langflow-username-spacer") - # Create new widgets - from textual.widgets import Label, Input, Static - label = Label("Langflow Admin Username *", id="langflow-username-label") - input_widget = Input( - placeholder="admin", - value=getattr(self.env_manager.config, "langflow_superuser", "admin"), - id="input-langflow_superuser" - ) - spacer = Static(" ", id="langflow-username-spacer") - - # Mount them after the password row's spacer - password_spacer.mount_after(label) - label.mount_after(input_widget) - input_widget.mount_after(spacer) - - self.inputs["langflow_superuser"] = input_widget - - elif not has_password and username_exists: - # Hide username field - for widget in username_input: - widget.remove() - for widget in username_label: - widget.remove() - for widget in username_spacer: - widget.remove() - - # Remove from inputs dict - if "langflow_superuser" in self.inputs: - del self.inputs["langflow_superuser"] + # Show or hide based on password presence + if has_password: + username_label.display = True + username_input.display = True + username_spacer.display = True + else: + username_label.display = False + username_input.display = False + username_spacer.display = False + except Exception: + # Widgets don't exist yet, ignore + pass From e32a8387bf3fe30ee69120ba86b9c65243d861b1 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 22:09:57 -0400 Subject: [PATCH 12/14] transparently handle langflow auto login etc. --- src/tui/screens/config.py | 46 ++------------------------------------- 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index d7243297..7aae6f85 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -383,50 +383,8 @@ class ConfigScreen(Screen): self.inputs["openrag_documents_paths"] = input_widget yield Static(" ") - # Langflow Auth Settings - yield Static("Langflow Auth Settings", classes="tab-header") - yield Static(" ") - - # Langflow Auto Login - yield Label("Langflow Auto Login") - current_value = getattr(self.env_manager.config, "langflow_auto_login", "False") - input_widget = Input( - placeholder="False", value=current_value, id="input-langflow_auto_login" - ) - yield input_widget - self.inputs["langflow_auto_login"] = input_widget - yield Static(" ") - - # Langflow New User Is Active - yield Label("Langflow New User Is Active") - current_value = getattr( - self.env_manager.config, "langflow_new_user_is_active", "False" - ) - input_widget = Input( - placeholder="False", - value=current_value, - id="input-langflow_new_user_is_active", - ) - yield input_widget - self.inputs["langflow_new_user_is_active"] = input_widget - yield Static(" ") - - # Langflow Enable Superuser CLI - yield Label("Langflow Enable Superuser CLI") - current_value = getattr( - self.env_manager.config, "langflow_enable_superuser_cli", "False" - ) - input_widget = Input( - placeholder="False", - value=current_value, - id="input-langflow_enable_superuser_cli", - ) - yield input_widget - self.inputs["langflow_enable_superuser_cli"] = input_widget - yield Static(" ") - yield Static(" ") - - # Langflow Secret Key removed from UI; generated automatically on save + # Langflow Auth Settings - These are automatically configured based on password presence + # Not shown in UI; set in env_manager.setup_secure_defaults() # Add optional fields only in full mode if self.mode == "full": From b45a26940edd9e665ff865e326dbd1d0140f5be8 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 22:44:12 -0400 Subject: [PATCH 13/14] fix generate password bug --- src/tui/screens/config.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 7aae6f85..a9ef334d 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -525,7 +525,12 @@ class ConfigScreen(Screen): def action_generate(self) -> None: """Generate secure passwords for admin accounts.""" - # Only generate OpenSearch password - leave Langflow empty for autologin mode + # First sync input values to config to get current state + opensearch_input = self.inputs.get("opensearch_password") + if opensearch_input: + self.env_manager.config.opensearch_password = opensearch_input.value + + # Only generate OpenSearch password if empty if not self.env_manager.config.opensearch_password: self.env_manager.config.opensearch_password = self.env_manager.generate_secure_password() @@ -533,14 +538,9 @@ class ConfigScreen(Screen): if not self.env_manager.config.langflow_secret_key: self.env_manager.config.langflow_secret_key = self.env_manager.generate_langflow_secret_key() - if not self.env_manager.config.session_secret: - self.env_manager.config.session_secret = self.env_manager.generate_session_secret() - # Update input fields with generated values - for field_name, input_widget in self.inputs.items(): - if field_name == "opensearch_password": - new_value = getattr(self.env_manager.config, field_name) - input_widget.value = new_value + if opensearch_input: + opensearch_input.value = self.env_manager.config.opensearch_password self.notify("Generated secure password for OpenSearch", severity="information") From 83aa3ba9a9857667d37649aa4607c5aee7abc006 Mon Sep 17 00:00:00 2001 From: phact Date: Wed, 8 Oct 2025 23:04:25 -0400 Subject: [PATCH 14/14] docling health copy --- frontend/components/docling-health-banner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/docling-health-banner.tsx b/frontend/components/docling-health-banner.tsx index c65a93cc..afa79deb 100644 --- a/frontend/components/docling-health-banner.tsx +++ b/frontend/components/docling-health-banner.tsx @@ -69,7 +69,7 @@ function DoclingSetupDialog({ - Then, select Start Native Services in the TUI. Once docling-serve is running, refresh OpenRAG. + Then, select Start All Services in the TUI. Once docling-serve is running, refresh OpenRAG.