diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index d1258df8..24ed62f9 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -144,14 +144,15 @@ class ContainerManager: ) # Simple approach: read line by line and yield each one - while True: - line = await process.stdout.readline() - if not line: - break + if process.stdout: + while True: + line = await process.stdout.readline() + if not line: + break - line_text = line.decode().rstrip() - if line_text: - yield line_text + line_text = line.decode(errors="ignore").rstrip() + if line_text: + yield line_text # Wait for process to complete await process.wait() @@ -159,6 +160,59 @@ class ContainerManager: except Exception as e: yield f"Command execution failed: {e}" + 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.""" + if not self.is_available(): + success_flag["value"] = False + yield "No container runtime available" + return + + if cpu_mode is None: + cpu_mode = self.use_cpu_compose + compose_file = self.cpu_compose_file if cpu_mode else self.compose_file + cmd = self.runtime_info.compose_command + ["-f", str(compose_file)] + args + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=Path.cwd(), + ) + except Exception as e: + success_flag["value"] = False + yield f"Command execution failed: {e}" + return + + success_flag["value"] = True + + if process.stdout: + while True: + line = await process.stdout.readline() + if not line: + 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 + + returncode = await process.wait() + if returncode != 0: + success_flag["value"] = False + yield f"Command exited with status {returncode}" + async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]: """Run a runtime command (docker/podman) and return (success, stdout, stderr).""" if not self.is_available(): @@ -408,19 +462,42 @@ class ContainerManager: return results async def start_services( - self, cpu_mode: bool = False + self, cpu_mode: Optional[bool] = None ) -> AsyncIterator[tuple[bool, str]]: """Start all services and yield progress updates.""" + if not self.is_available(): + yield False, "No container runtime available" + return + yield False, "Starting OpenRAG services..." - success, stdout, stderr = await self._run_compose_command( - ["up", "-d"], cpu_mode - ) + missing_images: List[str] = [] + try: + images_info = await self.get_project_images_info() + missing_images = [image for image, digest in images_info if digest == "-"] + except Exception: + missing_images = [] - if success: + if missing_images: + images_list = ", ".join(missing_images) + yield False, f"Pulling container images ({images_list})..." + pull_success = {"value": True} + async for line in self._stream_compose_command( + ["pull"], pull_success, cpu_mode + ): + yield False, line + if not pull_success["value"]: + yield False, "Some images failed to pull; attempting to start services anyway..." + + yield False, "Creating and starting containers..." + up_success = {"value": True} + async for line in self._stream_compose_command(["up", "-d"], up_success, cpu_mode): + yield False, line + + if up_success["value"]: yield True, "Services started successfully" else: - yield False, f"Failed to start services: {stderr}" + yield False, "Failed to start services. See output above for details." async def stop_services(self) -> AsyncIterator[tuple[bool, str]]: """Stop all services and yield progress updates.""" diff --git a/src/tui/screens/diagnostics.py b/src/tui/screens/diagnostics.py index 3be628f2..bad456e4 100644 --- a/src/tui/screens/diagnostics.py +++ b/src/tui/screens/diagnostics.py @@ -10,10 +10,11 @@ from typing import List, Optional from textual.app import ComposeResult from textual.containers import Container, Vertical, Horizontal, ScrollableContainer from textual.screen import Screen -from textual.widgets import Header, Footer, Static, Button, Label, Log +from textual.widgets import Header, Footer, Static, Button, Log from rich.text import Text from ..managers.container_manager import ContainerManager +from ..utils.clipboard import copy_text_to_clipboard class DiagnosticsScreen(Screen): @@ -117,67 +118,13 @@ class DiagnosticsScreen(Screen): content = "\n".join(str(line) for line in log.lines) status = self.query_one("#copy-status", Static) - # Try to use pyperclip if available - try: - import pyperclip - - pyperclip.copy(content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - self._hide_status_after_delay(status) - return - except ImportError: - pass - - # Fallback to platform-specific clipboard commands - import subprocess - import platform - - system = platform.system() - if system == "Darwin": # macOS - process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, text=True) - process.communicate(input=content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - elif system == "Windows": - process = subprocess.Popen(["clip"], stdin=subprocess.PIPE, text=True) - process.communicate(input=content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - elif system == "Linux": - # Try xclip first, then xsel - try: - process = subprocess.Popen( - ["xclip", "-selection", "clipboard"], - stdin=subprocess.PIPE, - text=True, - ) - process.communicate(input=content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - except FileNotFoundError: - try: - process = subprocess.Popen( - ["xsel", "--clipboard", "--input"], - stdin=subprocess.PIPE, - text=True, - ) - process.communicate(input=content) - self.notify("Copied to clipboard", severity="information") - status.update("✓ Content copied to clipboard") - except FileNotFoundError: - self.notify( - "Clipboard utilities not found. Install xclip or xsel.", - severity="error", - ) - status.update( - "❌ Clipboard utilities not found. Install xclip or xsel." - ) + success, message = copy_text_to_clipboard(content) + if success: + self.notify(message, severity="information") + status.update(f"✓ {message}") else: - self.notify( - "Clipboard not supported on this platform", severity="error" - ) - status.update("❌ Clipboard not supported on this platform") + self.notify(message, severity="error") + status.update(f"❌ {message}") self._hide_status_after_delay(status) except Exception as e: diff --git a/src/tui/utils/clipboard.py b/src/tui/utils/clipboard.py new file mode 100644 index 00000000..4548a76c --- /dev/null +++ b/src/tui/utils/clipboard.py @@ -0,0 +1,50 @@ +"""Clipboard helper utilities for the TUI.""" + +from __future__ import annotations + +import platform +import subprocess +from typing import Tuple + + +def copy_text_to_clipboard(text: str) -> Tuple[bool, str]: + """Copy ``text`` to the system clipboard. + + Returns a tuple of (success, message) so callers can surface feedback to users. + """ + # Try optional dependency first for cross-platform consistency + try: + import pyperclip # type: ignore + + pyperclip.copy(text) + return True, "Copied to clipboard" + except ImportError: + # Fall back to platform-specific commands + pass + except Exception as exc: # pragma: no cover - defensive catch for pyperclip edge cases + return False, f"Clipboard error: {exc}" + + system = platform.system() + + try: + if system == "Darwin": + process = subprocess.Popen(["pbcopy"], stdin=subprocess.PIPE, text=True) + process.communicate(input=text) + return True, "Copied to clipboard" + if system == "Windows": + process = subprocess.Popen(["clip"], stdin=subprocess.PIPE, text=True) + process.communicate(input=text) + return True, "Copied to clipboard" + if system == "Linux": + for command in (["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]): + try: + process = subprocess.Popen(command, stdin=subprocess.PIPE, text=True) + process.communicate(input=text) + return True, "Copied to clipboard" + except FileNotFoundError: + continue + return False, "Clipboard utilities not found. Install xclip or xsel." + return False, "Clipboard not supported on this platform" + except Exception as exc: # pragma: no cover - subprocess errors + return False, f"Clipboard error: {exc}" + diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index 8f703648..015861f0 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -2,14 +2,15 @@ import asyncio import inspect -from typing import Callable, List, Optional, AsyncIterator, Any +from typing import Callable, Optional, AsyncIterator +from rich.text import Text from textual.app import ComposeResult -from textual.worker import Worker from textual.containers import Container, ScrollableContainer from textual.screen import ModalScreen -from textual.widgets import Button, Static, Label, RichLog -from rich.console import Console +from textual.widgets import Button, Static, Label, TextArea + +from ..utils.clipboard import copy_text_to_clipboard class CommandOutputModal(ModalScreen): @@ -46,11 +47,14 @@ class CommandOutputModal(ModalScreen): #command-output { height: 100%; border: solid $accent; - padding: 1 2; margin: 1 0; background: $surface-darken-1; } + #command-output > .text-area--content { + padding: 1 2; + } + #button-row { width: 100%; height: auto; @@ -63,6 +67,11 @@ class CommandOutputModal(ModalScreen): margin: 0 1; min-width: 16; } + + #copy-status { + text-align: center; + margin-bottom: 1; + } """ def __init__( @@ -82,44 +91,66 @@ class CommandOutputModal(ModalScreen): self.title_text = title self.command_generator = command_generator self.on_complete = on_complete + self._output_text: str = "" + 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 RichLog(id="command-output", highlight=True, markup=True) + yield TextArea( + text="", + read_only=True, + show_line_numbers=False, + id="command-output", + ) with Container(id="button-row"): - yield Button("Close", variant="primary", id="close-btn") + yield Button("Copy Output", variant="default", id="copy-btn") + yield Button( + "Close", variant="primary", id="close-btn", disabled=True + ) + yield Static("", id="copy-status") def on_mount(self) -> None: """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.""" + if self._status_task: + self._status_task.cancel() + self._status_task = None def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "close-btn": self.dismiss() + elif event.button.id == "copy-btn": + self.copy_to_clipboard() async def _run_command(self) -> None: """Run the command and update the output in real-time.""" - output = self.query_one("#command-output", RichLog) + output = self.query_one("#command-output", TextArea) + container = self.query_one("#output-container", ScrollableContainer) try: async for is_complete, message in self.command_generator: - # Simple approach: just append each line as it comes - output.write(message + "\n") - - # Scroll to bottom - container = self.query_one("#output-container", ScrollableContainer) + self._append_output(message) + output.text = self._output_text container.scroll_end(animate=False) # If command is complete, update UI if is_complete: - output.write( - "[bold green]Command completed successfully[/bold green]\n" - ) + self._append_output("Command completed successfully") + output.text = self._output_text + container.scroll_end(animate=False) # Call the completion callback if provided if self.on_complete: await asyncio.sleep(0.5) # Small delay for better UX @@ -131,12 +162,57 @@ class CommandOutputModal(ModalScreen): self.call_after_refresh(_invoke_callback) except Exception as e: - output.write(f"[bold red]Error: {e}[/bold red]\n") + self._append_output(f"Error: {e}") + output.text = self._output_text + container.scroll_end(animate=False) + finally: + # Enable the close button and focus it + close_btn = self.query_one("#close-btn", Button) + close_btn.disabled = False + close_btn.focus() - # 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.""" + if message is None: + return + message = message.rstrip("\n") + if not message: + return + if self._output_text: + self._output_text += "\n" + message + else: + self._output_text = message + + def copy_to_clipboard(self) -> None: + """Copy the modal output to the clipboard.""" + if not self._output_text: + message = "No output to copy yet" + self.notify(message, severity="warning") + status = self.query_one("#copy-status", Static) + status.update(Text(message, style="bold yellow")) + self._schedule_status_clear(status) + return + + success, message = copy_text_to_clipboard(self._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" + status.update(Text(message, style=style)) + self._schedule_status_clear(status) + + def _schedule_status_clear(self, widget: Static, delay: float = 3.0) -> None: + """Clear the status message after a delay.""" + if self._status_task: + self._status_task.cancel() + + async def _clear() -> None: + try: + await asyncio.sleep(delay) + widget.update("") + except asyncio.CancelledError: + pass + + self._status_task = asyncio.create_task(_clear()) # Made with Bob