tui improvements
This commit is contained in:
parent
62bfe11f34
commit
ec2295ce67
4 changed files with 245 additions and 95 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
50
src/tui/utils/clipboard.py
Normal file
50
src/tui/utils/clipboard.py
Normal 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}"
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue