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"