tui improvements

This commit is contained in:
phact 2025-09-17 21:09:06 -04:00
parent 62bfe11f34
commit ec2295ce67
4 changed files with 245 additions and 95 deletions

View file

@ -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."""

View file

@ -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:

View file

@ -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}"

View file

@ -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