+
+
Project Knowledge
+
+
{/* Search Input Area */}
-
+
-
- {/* Results Area */}
-
-
- {fileResults.length === 0 && !isFetching ? (
-
+ {selectedFile ? (
+ // Show chunks for selected file
+ <>
+
+
+
+ Chunks from {selectedFile}
+
+
+ {fileResults
+ .filter((file) => file.filename === selectedFile)
+ .flatMap((file) => file.chunks)
+ .map((chunk, index) => (
+
+
+
+
+
+ {chunk.filename}
+
+
+
+ {chunk.score.toFixed(2)}
+
+
+
+ {chunk.mimetype} • Page {chunk.page}
+
+
+ {chunk.text}
+
+
+ ))}
+ >
+ ) : (
+
) => {
+ setSelectedFile(params.data?.filename ?? "");
+ }}
+ noRowsOverlayComponent={() => (
+
No documents found
@@ -128,140 +272,9 @@ function SearchPage() {
Try adjusting your search terms
- ) : (
-
- {/* Results Count */}
-
-
- {fileResults.length} file
- {fileResults.length !== 1 ? "s" : ""} found
-
-
-
- {/* Results Display */}
-
- {selectedFile ? (
- // Show chunks for selected file
- <>
-
-
-
- Chunks from {selectedFile}
-
-
- {fileResults
- .filter((file) => file.filename === selectedFile)
- .flatMap((file) => file.chunks)
- .map((chunk, index) => (
-
-
-
-
-
- {chunk.filename}
-
-
-
- {chunk.score.toFixed(2)}
-
-
-
- {chunk.mimetype} • Page {chunk.page}
-
-
- {chunk.text}
-
-
- ))}
- >
- ) : (
- // Show files table
-
-
-
-
- |
- Source
- |
-
- Type
- |
-
- Size
- |
-
- Matching chunks
- |
-
- Average score
- |
-
- Owner
- |
-
-
-
- {fileResults.map((file) => (
- setSelectedFile(file.filename)}
- >
- |
-
- {getSourceIcon(file.connector_type)}
-
- {file.filename}
-
-
- |
-
- {file.mimetype}
- |
-
- {file.size
- ? `${Math.round(file.size / 1024)} KB`
- : "—"}
- |
-
- {file.chunkCount}
- |
-
-
- {file.avgScore.toFixed(2)}
-
- |
-
- {file.owner_name || file.owner || "—"}
- |
-
- ))}
-
-
-
- )}
-
-
)}
-
-
+ />
+ )}
);
diff --git a/frontend/src/components/AgGrid/agGridStyles.css b/frontend/src/components/AgGrid/agGridStyles.css
new file mode 100644
index 00000000..b595e18c
--- /dev/null
+++ b/frontend/src/components/AgGrid/agGridStyles.css
@@ -0,0 +1,21 @@
+body {
+ --ag-text-color: hsl(var(--muted-foreground));
+ --ag-background-color: hsl(var(--background));
+ --ag-header-background-color: hsl(var(--background));
+ --ag-header-text-color: hsl(var(--muted-foreground));
+ --ag-header-column-resize-handle-color: hsl(var(--border));
+ --ag-header-row-border: hsl(var(--border));
+ --ag-header-font-weight: var(--font-medium);
+ --ag-row-border: undefined;
+ --ag-row-hover-color: hsl(var(--muted));
+ --ag-wrapper-border: none;
+ --ag-font-family: var(--font-sans);
+
+ .ag-header {
+ border-bottom: 1px solid hsl(var(--border));
+ margin-bottom: 0.5rem;
+ }
+ .ag-row {
+ cursor: pointer;
+ }
+}
diff --git a/frontend/src/components/AgGrid/registerAgGridModules.ts b/frontend/src/components/AgGrid/registerAgGridModules.ts
new file mode 100644
index 00000000..da2c5280
--- /dev/null
+++ b/frontend/src/components/AgGrid/registerAgGridModules.ts
@@ -0,0 +1,33 @@
+import {
+ ModuleRegistry,
+ ValidationModule,
+ ColumnAutoSizeModule,
+ ColumnApiModule,
+ PaginationModule,
+ CellStyleModule,
+ QuickFilterModule,
+ ClientSideRowModelModule,
+ TextFilterModule,
+ DateFilterModule,
+ EventApiModule,
+ GridStateModule,
+ } from 'ag-grid-community';
+
+ // Importing necessary modules from ag-grid-community
+ // https://www.ag-grid.com/javascript-data-grid/modules/#selecting-modules
+
+ ModuleRegistry.registerModules([
+ ColumnAutoSizeModule,
+ ColumnApiModule,
+ PaginationModule,
+ CellStyleModule,
+ QuickFilterModule,
+ ClientSideRowModelModule,
+ TextFilterModule,
+ DateFilterModule,
+ EventApiModule,
+ GridStateModule,
+ // The ValidationModule adds helpful console warnings/errors that can help identify bad configuration during development.
+ ...(process.env.NODE_ENV !== 'production' ? [ValidationModule] : []),
+ ]);
+
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 86348ee5..a2a0e41f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "openrag"
-version = "0.1.3"
+version = "0.1.8"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
@@ -37,6 +37,7 @@ openrag = "tui.main:run_tui"
[tool.uv]
package = true
+
[tool.uv.sources]
torch = [
{ index = "pytorch-cu128", marker = "sys_platform == 'linux' and platform_machine == 'x86_64'" },
diff --git a/src/services/task_service.py b/src/services/task_service.py
index 8e69d4ae..0341aadf 100644
--- a/src/services/task_service.py
+++ b/src/services/task_service.py
@@ -85,6 +85,8 @@ class TaskService:
async def create_custom_task(self, user_id: str, items: list, processor) -> str:
"""Create a new task with custom processor for any type of items"""
+ # Store anonymous tasks under a stable key so they can be retrieved later
+ store_user_id = user_id or AnonymousUser().user_id
task_id = str(uuid.uuid4())
upload_task = UploadTask(
task_id=task_id,
@@ -95,12 +97,14 @@ class TaskService:
# Attach the custom processor to the task
upload_task.processor = processor
- if user_id not in self.task_store:
- self.task_store[user_id] = {}
- self.task_store[user_id][task_id] = upload_task
+ if store_user_id not in self.task_store:
+ self.task_store[store_user_id] = {}
+ self.task_store[store_user_id][task_id] = upload_task
# Start background processing
- background_task = asyncio.create_task(self.background_custom_processor(user_id, task_id, items))
+ background_task = asyncio.create_task(
+ self.background_custom_processor(store_user_id, task_id, items)
+ )
self.background_tasks.add(background_task)
background_task.add_done_callback(self.background_tasks.discard)
diff --git a/src/tui/_assets/docker-compose-cpu.yml b/src/tui/_assets/docker-compose-cpu.yml
new file mode 100644
index 00000000..06d44643
--- /dev/null
+++ b/src/tui/_assets/docker-compose-cpu.yml
@@ -0,0 +1,111 @@
+services:
+ opensearch:
+ image: phact/openrag-opensearch:${OPENRAG_VERSION:-latest}
+ #build:
+ # context: .
+ # dockerfile: Dockerfile
+ container_name: os
+ depends_on:
+ - openrag-backend
+ environment:
+ - discovery.type=single-node
+ - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD}
+ # Run security setup in background after OpenSearch starts
+ command: >
+ bash -c "
+ # Start OpenSearch in background
+ /usr/share/opensearch/opensearch-docker-entrypoint.sh opensearch &
+
+ # Wait a bit for OpenSearch to start, then apply security config
+ sleep 10 && /usr/share/opensearch/setup-security.sh &
+
+ # Wait for background processes
+ wait
+ "
+ ports:
+ - "9200:9200"
+ - "9600:9600"
+
+ dashboards:
+ image: opensearchproject/opensearch-dashboards:3.0.0
+ container_name: osdash
+ depends_on:
+ - opensearch
+ environment:
+ OPENSEARCH_HOSTS: '["https://opensearch:9200"]'
+ OPENSEARCH_USERNAME: "admin"
+ OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD}
+ ports:
+ - "5601:5601"
+
+ openrag-backend:
+ image: phact/openrag-backend:${OPENRAG_VERSION:-latest}
+ #build:
+ #context: .
+ #dockerfile: Dockerfile.backend
+ container_name: openrag-backend
+ depends_on:
+ - langflow
+ environment:
+ - OPENSEARCH_HOST=opensearch
+ - LANGFLOW_URL=http://langflow:7860
+ - LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
+ - LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
+ - LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
+ - LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
+ - LANGFLOW_CHAT_FLOW_ID=${LANGFLOW_CHAT_FLOW_ID}
+ - LANGFLOW_INGEST_FLOW_ID=${LANGFLOW_INGEST_FLOW_ID}
+ - DISABLE_INGEST_WITH_LANGFLOW=${DISABLE_INGEST_WITH_LANGFLOW:-false}
+ - NUDGES_FLOW_ID=${NUDGES_FLOW_ID}
+ - OPENSEARCH_PORT=9200
+ - OPENSEARCH_USERNAME=admin
+ - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - NVIDIA_DRIVER_CAPABILITIES=compute,utility
+ - NVIDIA_VISIBLE_DEVICES=all
+ - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID}
+ - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET}
+ - MICROSOFT_GRAPH_OAUTH_CLIENT_ID=${MICROSOFT_GRAPH_OAUTH_CLIENT_ID}
+ - MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET=${MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET}
+ - WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL}
+ - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
+ - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
+ volumes:
+ - ./documents:/app/documents:Z
+ - ./keys:/app/keys:Z
+ - ./flows:/app/flows:Z
+
+ openrag-frontend:
+ image: phact/openrag-frontend:${OPENRAG_VERSION:-latest}
+ #build:
+ #context: .
+ #dockerfile: Dockerfile.frontend
+ container_name: openrag-frontend
+ depends_on:
+ - openrag-backend
+ environment:
+ - OPENRAG_BACKEND_HOST=openrag-backend
+ ports:
+ - "3000:3000"
+
+ langflow:
+ volumes:
+ - ./flows:/app/flows:Z
+ image: phact/langflow:${LANGFLOW_VERSION:-responses}
+ container_name: langflow
+ ports:
+ - "7860:7860"
+ environment:
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - LANGFLOW_LOAD_FLOWS_PATH=/app/flows
+ - LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
+ - JWT="dummy"
+ - OPENRAG-QUERY-FILTER="{}"
+ - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
+ - LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER,OPENSEARCH_PASSWORD
+ - LANGFLOW_LOG_LEVEL=DEBUG
+ - LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
+ - LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
+ - LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
+ - LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE}
+ - LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI}
diff --git a/src/tui/_assets/docker-compose.yml b/src/tui/_assets/docker-compose.yml
new file mode 100644
index 00000000..997cf463
--- /dev/null
+++ b/src/tui/_assets/docker-compose.yml
@@ -0,0 +1,111 @@
+services:
+ opensearch:
+ image: phact/openrag-opensearch:${OPENRAG_VERSION:-latest}
+ #build:
+ #context: .
+ #dockerfile: Dockerfile
+ container_name: os
+ depends_on:
+ - openrag-backend
+ environment:
+ - discovery.type=single-node
+ - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD}
+ # Run security setup in background after OpenSearch starts
+ command: >
+ bash -c "
+ # Start OpenSearch in background
+ /usr/share/opensearch/opensearch-docker-entrypoint.sh opensearch &
+
+ # Wait a bit for OpenSearch to start, then apply security config
+ sleep 10 && /usr/share/opensearch/setup-security.sh &
+
+ # Wait for background processes
+ wait
+ "
+ ports:
+ - "9200:9200"
+ - "9600:9600"
+
+ dashboards:
+ image: opensearchproject/opensearch-dashboards:3.0.0
+ container_name: osdash
+ depends_on:
+ - opensearch
+ environment:
+ OPENSEARCH_HOSTS: '["https://opensearch:9200"]'
+ OPENSEARCH_USERNAME: "admin"
+ OPENSEARCH_PASSWORD: ${OPENSEARCH_PASSWORD}
+ ports:
+ - "5601:5601"
+
+ openrag-backend:
+ image: phact/openrag-backend:${OPENRAG_VERSION:-latest}
+ #build:
+ #context: .
+ #dockerfile: Dockerfile.backend
+ container_name: openrag-backend
+ depends_on:
+ - langflow
+ environment:
+ - OPENSEARCH_HOST=opensearch
+ - LANGFLOW_URL=http://langflow:7860
+ - LANGFLOW_PUBLIC_URL=${LANGFLOW_PUBLIC_URL}
+ - LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
+ - LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
+ - LANGFLOW_CHAT_FLOW_ID=${LANGFLOW_CHAT_FLOW_ID}
+ - LANGFLOW_INGEST_FLOW_ID=${LANGFLOW_INGEST_FLOW_ID}
+ - DISABLE_INGEST_WITH_LANGFLOW=${DISABLE_INGEST_WITH_LANGFLOW:-false}
+ - NUDGES_FLOW_ID=${NUDGES_FLOW_ID}
+ - OPENSEARCH_PORT=9200
+ - OPENSEARCH_USERNAME=admin
+ - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - NVIDIA_DRIVER_CAPABILITIES=compute,utility
+ - NVIDIA_VISIBLE_DEVICES=all
+ - GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID}
+ - GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET}
+ - MICROSOFT_GRAPH_OAUTH_CLIENT_ID=${MICROSOFT_GRAPH_OAUTH_CLIENT_ID}
+ - MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET=${MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET}
+ - WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL}
+ - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
+ - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
+ volumes:
+ - ./documents:/app/documents:Z
+ - ./keys:/app/keys:Z
+ - ./flows:/app/flows:Z
+ gpus: all
+
+ openrag-frontend:
+ image: phact/openrag-frontend:${OPENRAG_VERSION:-latest}
+ #build:
+ #context: .
+ #dockerfile: Dockerfile.frontend
+ container_name: openrag-frontend
+ depends_on:
+ - openrag-backend
+ environment:
+ - OPENRAG_BACKEND_HOST=openrag-backend
+ ports:
+ - "3000:3000"
+
+ langflow:
+ volumes:
+ - ./flows:/app/flows:Z
+ image: phact/langflow:${LANGFLOW_VERSION:-responses}
+ container_name: langflow
+ ports:
+ - "7860:7860"
+ environment:
+ - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - LANGFLOW_LOAD_FLOWS_PATH=/app/flows
+ - LANGFLOW_SECRET_KEY=${LANGFLOW_SECRET_KEY}
+ - JWT="dummy"
+ - OPENRAG-QUERY-FILTER="{}"
+ - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD}
+ - LANGFLOW_VARIABLES_TO_GET_FROM_ENVIRONMENT=JWT,OPENRAG-QUERY-FILTER,OPENSEARCH_PASSWORD
+ - LANGFLOW_LOG_LEVEL=DEBUG
+ - LANGFLOW_AUTO_LOGIN=${LANGFLOW_AUTO_LOGIN}
+ - LANGFLOW_SUPERUSER=${LANGFLOW_SUPERUSER}
+ - LANGFLOW_SUPERUSER_PASSWORD=${LANGFLOW_SUPERUSER_PASSWORD}
+ - LANGFLOW_NEW_USER_IS_ACTIVE=${LANGFLOW_NEW_USER_IS_ACTIVE}
+ - LANGFLOW_ENABLE_SUPERUSER_CLI=${LANGFLOW_ENABLE_SUPERUSER_CLI}
diff --git a/src/tui/_assets/documents/2506.08231v1.pdf b/src/tui/_assets/documents/2506.08231v1.pdf
new file mode 100644
index 00000000..61e83265
Binary files /dev/null and b/src/tui/_assets/documents/2506.08231v1.pdf differ
diff --git a/src/tui/_assets/documents/ai-human-resources.pdf b/src/tui/_assets/documents/ai-human-resources.pdf
new file mode 100644
index 00000000..5e36eab4
Binary files /dev/null and b/src/tui/_assets/documents/ai-human-resources.pdf differ
diff --git a/src/tui/_assets/documents/warmup_ocr.pdf b/src/tui/_assets/documents/warmup_ocr.pdf
new file mode 100644
index 00000000..8b17f8b2
Binary files /dev/null and b/src/tui/_assets/documents/warmup_ocr.pdf differ
diff --git a/src/tui/main.py b/src/tui/main.py
index c2a785f3..b68293fe 100644
--- a/src/tui/main.py
+++ b/src/tui/main.py
@@ -4,6 +4,10 @@ import sys
from pathlib import Path
from textual.app import App, ComposeResult
from utils.logging_config import get_logger
+try:
+ from importlib.resources import files
+except ImportError:
+ from importlib_resources import files
logger = get_logger(__name__)
@@ -301,10 +305,42 @@ class OpenRAGTUI(App):
return True, "Runtime requirements satisfied"
+def copy_sample_documents():
+ """Copy sample documents from package to current directory if they don't exist."""
+ documents_dir = Path("documents")
+
+ # Check if documents directory already exists and has files
+ if documents_dir.exists() and any(documents_dir.glob("*.pdf")):
+ return # Documents already exist, don't overwrite
+
+ try:
+ # Get sample documents from package assets
+ assets_files = files("tui._assets.documents")
+
+ # Create documents directory if it doesn't exist
+ documents_dir.mkdir(exist_ok=True)
+
+ # Copy each sample document
+ for resource in assets_files.iterdir():
+ if resource.is_file() and resource.name.endswith('.pdf'):
+ dest_path = documents_dir / resource.name
+ if not dest_path.exists():
+ content = resource.read_bytes()
+ dest_path.write_bytes(content)
+ logger.info(f"Copied sample document: {resource.name}")
+
+ except Exception as e:
+ logger.debug(f"Could not copy sample documents: {e}")
+ # This is not a critical error - the app can work without sample documents
+
+
def run_tui():
"""Run the OpenRAG TUI application."""
app = None
try:
+ # Copy sample documents on first run
+ copy_sample_documents()
+
app = OpenRAGTUI()
app.run()
except KeyboardInterrupt:
diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py
index d1258df8..d1be1e9f 100644
--- a/src/tui/managers/container_manager.py
+++ b/src/tui/managers/container_manager.py
@@ -9,6 +9,10 @@ from enum import Enum
from pathlib import Path
from typing import Dict, List, Optional, AsyncIterator
from utils.logging_config import get_logger
+try:
+ from importlib.resources import files
+except ImportError:
+ from importlib_resources import files
logger = get_logger(__name__)
@@ -51,8 +55,8 @@ class ContainerManager:
def __init__(self, compose_file: Optional[Path] = None):
self.platform_detector = PlatformDetector()
self.runtime_info = self.platform_detector.detect_runtime()
- self.compose_file = compose_file or Path("docker-compose.yml")
- self.cpu_compose_file = Path("docker-compose-cpu.yml")
+ self.compose_file = compose_file or self._find_compose_file("docker-compose.yml")
+ self.cpu_compose_file = self._find_compose_file("docker-compose-cpu.yml")
self.services_cache: Dict[str, ServiceInfo] = {}
self.last_status_update = 0
# Auto-select CPU compose if no GPU available
@@ -80,6 +84,42 @@ class ContainerManager:
"langflow": "langflow",
}
+ def _find_compose_file(self, filename: str) -> Path:
+ """Find compose file in current directory or package resources."""
+ # First check current working directory
+ cwd_path = Path(filename)
+ self._compose_search_log = f"Searching for {filename}:\n"
+ self._compose_search_log += f" 1. Current directory: {cwd_path.absolute()}"
+
+ if cwd_path.exists():
+ self._compose_search_log += " ✓ FOUND"
+ return cwd_path
+ else:
+ self._compose_search_log += " ✗ NOT FOUND"
+
+ # Then check package resources
+ self._compose_search_log += f"\n 2. Package resources: "
+ try:
+ pkg_files = files("tui._assets")
+ self._compose_search_log += f"{pkg_files}"
+ compose_resource = pkg_files / filename
+
+ if compose_resource.is_file():
+ self._compose_search_log += f" ✓ FOUND, copying to current directory"
+ # Copy to cwd for compose command to work
+ content = compose_resource.read_text()
+ cwd_path.write_text(content)
+ return cwd_path
+ else:
+ self._compose_search_log += f" ✗ NOT FOUND"
+ except Exception as e:
+ self._compose_search_log += f" ✗ SKIPPED ({e})"
+ # Don't log this as an error since it's expected when running from source
+
+ # Fall back to original path (will fail later if not found)
+ self._compose_search_log += f"\n 3. Falling back to: {cwd_path.absolute()}"
+ return Path(filename)
+
def is_available(self) -> bool:
"""Check if container runtime is available."""
return self.runtime_info.runtime_type != RuntimeType.NONE
@@ -144,14 +184,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 +200,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 +502,56 @@ 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
+
+ # Diagnostic info about compose files
+ compose_file = self.cpu_compose_file if (cpu_mode if cpu_mode is not None else self.use_cpu_compose) else self.compose_file
+
+ # Show the search process for debugging
+ if hasattr(self, '_compose_search_log'):
+ for line in self._compose_search_log.split('\n'):
+ if line.strip():
+ yield False, line
+
+ yield False, f"Final compose file: {compose_file.absolute()}"
+ if not compose_file.exists():
+ yield False, f"ERROR: Compose file not found at {compose_file.absolute()}"
+ 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/managers/env_manager.py b/src/tui/managers/env_manager.py
index 4c4f294d..9954b463 100644
--- a/src/tui/managers/env_manager.py
+++ b/src/tui/managers/env_manager.py
@@ -79,6 +79,15 @@ class EnvManager:
"""Generate a secure secret key for Langflow."""
return secrets.token_urlsafe(32)
+ def _quote_env_value(self, value: str) -> str:
+ """Single quote all environment variable values for consistency."""
+ if not value:
+ return "''"
+
+ # Escape any existing single quotes by replacing ' with '\''
+ escaped_value = value.replace("'", "'\\''")
+ return f"'{escaped_value}'"
+
def load_existing_env(self) -> bool:
"""Load existing .env file if it exists."""
if not self.env_file.exists():
@@ -237,36 +246,36 @@ class EnvManager:
# Core settings
f.write("# Core settings\n")
- f.write(f"LANGFLOW_SECRET_KEY={self.config.langflow_secret_key}\n")
- f.write(f"LANGFLOW_SUPERUSER={self.config.langflow_superuser}\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.config.langflow_superuser_password}\n"
+ f"LANGFLOW_SUPERUSER_PASSWORD={self._quote_env_value(self.config.langflow_superuser_password)}\n"
)
- f.write(f"LANGFLOW_CHAT_FLOW_ID={self.config.langflow_chat_flow_id}\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.config.langflow_ingest_flow_id}\n"
+ f"LANGFLOW_INGEST_FLOW_ID={self._quote_env_value(self.config.langflow_ingest_flow_id)}\n"
)
- f.write(f"NUDGES_FLOW_ID={self.config.nudges_flow_id}\n")
- f.write(f"OPENSEARCH_PASSWORD={self.config.opensearch_password}\n")
- f.write(f"OPENAI_API_KEY={self.config.openai_api_key}\n")
+ f.write(f"NUDGES_FLOW_ID={self._quote_env_value(self.config.nudges_flow_id)}\n")
+ f.write(f"OPENSEARCH_PASSWORD={self._quote_env_value(self.config.opensearch_password)}\n")
+ f.write(f"OPENAI_API_KEY={self._quote_env_value(self.config.openai_api_key)}\n")
f.write(
- f"OPENRAG_DOCUMENTS_PATHS={self.config.openrag_documents_paths}\n"
+ f"OPENRAG_DOCUMENTS_PATHS={self._quote_env_value(self.config.openrag_documents_paths)}\n"
)
f.write("\n")
# Ingestion settings
f.write("# Ingestion settings\n")
- f.write(f"DISABLE_INGEST_WITH_LANGFLOW={self.config.disable_ingest_with_langflow}\n")
+ f.write(f"DISABLE_INGEST_WITH_LANGFLOW={self._quote_env_value(self.config.disable_ingest_with_langflow)}\n")
f.write("\n")
# Langflow auth settings
f.write("# Langflow auth settings\n")
- f.write(f"LANGFLOW_AUTO_LOGIN={self.config.langflow_auto_login}\n")
+ f.write(f"LANGFLOW_AUTO_LOGIN={self._quote_env_value(self.config.langflow_auto_login)}\n")
f.write(
- f"LANGFLOW_NEW_USER_IS_ACTIVE={self.config.langflow_new_user_is_active}\n"
+ f"LANGFLOW_NEW_USER_IS_ACTIVE={self._quote_env_value(self.config.langflow_new_user_is_active)}\n"
)
f.write(
- f"LANGFLOW_ENABLE_SUPERUSER_CLI={self.config.langflow_enable_superuser_cli}\n"
+ f"LANGFLOW_ENABLE_SUPERUSER_CLI={self._quote_env_value(self.config.langflow_enable_superuser_cli)}\n"
)
f.write("\n")
@@ -277,10 +286,10 @@ class EnvManager:
):
f.write("# Google OAuth settings\n")
f.write(
- f"GOOGLE_OAUTH_CLIENT_ID={self.config.google_oauth_client_id}\n"
+ f"GOOGLE_OAUTH_CLIENT_ID={self._quote_env_value(self.config.google_oauth_client_id)}\n"
)
f.write(
- f"GOOGLE_OAUTH_CLIENT_SECRET={self.config.google_oauth_client_secret}\n"
+ f"GOOGLE_OAUTH_CLIENT_SECRET={self._quote_env_value(self.config.google_oauth_client_secret)}\n"
)
f.write("\n")
@@ -290,10 +299,10 @@ class EnvManager:
):
f.write("# Microsoft Graph OAuth settings\n")
f.write(
- f"MICROSOFT_GRAPH_OAUTH_CLIENT_ID={self.config.microsoft_graph_oauth_client_id}\n"
+ f"MICROSOFT_GRAPH_OAUTH_CLIENT_ID={self._quote_env_value(self.config.microsoft_graph_oauth_client_id)}\n"
)
f.write(
- f"MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET={self.config.microsoft_graph_oauth_client_secret}\n"
+ f"MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET={self._quote_env_value(self.config.microsoft_graph_oauth_client_secret)}\n"
)
f.write("\n")
@@ -311,7 +320,7 @@ class EnvManager:
if not optional_written:
f.write("# Optional settings\n")
optional_written = True
- f.write(f"{var_name}={var_value}\n")
+ f.write(f"{var_name}={self._quote_env_value(var_value)}\n")
if optional_written:
f.write("\n")
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/screens/logs.py b/src/tui/screens/logs.py
index 74c1adf1..c426c6b4 100644
--- a/src/tui/screens/logs.py
+++ b/src/tui/screens/logs.py
@@ -10,11 +10,32 @@ from rich.text import Text
from ..managers.container_manager import ContainerManager
from ..managers.docling_manager import DoclingManager
+from ..utils.clipboard import copy_text_to_clipboard
class LogsScreen(Screen):
"""Logs viewing and monitoring screen."""
+ CSS = """
+ #main-container {
+ height: 1fr;
+ }
+
+ #logs-content {
+ height: 1fr;
+ padding: 1 1 0 1;
+ }
+
+ #logs-area {
+ height: 1fr;
+ min-height: 30;
+ }
+
+ #logs-button-row {
+ padding: 1 0 0 0;
+ }
+ """
+
BINDINGS = [
("escape", "back", "Back"),
("f", "follow", "Follow Logs"),
@@ -27,6 +48,7 @@ class LogsScreen(Screen):
("k", "scroll_up", "Scroll Up"),
("ctrl+u", "scroll_page_up", "Page Up"),
("ctrl+f", "scroll_page_down", "Page Down"),
+ ("ctrl+c", "copy_logs", "Copy Logs"),
]
def __init__(self, initial_service: str = "openrag-backend"):
@@ -51,17 +73,17 @@ class LogsScreen(Screen):
self.following = False
self.follow_task = None
self.auto_scroll = True
+ self._status_task = None
def compose(self) -> ComposeResult:
"""Create the logs screen layout."""
- yield Container(
- Vertical(
- Static(f"Service Logs: {self.current_service}", id="logs-title"),
- self._create_logs_area(),
- id="logs-content",
- ),
- id="main-container",
- )
+ with Container(id="main-container"):
+ with Vertical(id="logs-content"):
+ yield Static(f"Service Logs: {self.current_service}", id="logs-title")
+ yield self._create_logs_area()
+ with Horizontal(id="logs-button-row"):
+ yield Button("Copy to Clipboard", variant="default", id="copy-btn")
+ yield Static("", id="copy-status", classes="copy-indicator")
yield Footer()
def _create_logs_area(self) -> TextArea:
@@ -108,6 +130,9 @@ class LogsScreen(Screen):
def on_unmount(self) -> None:
"""Clean up when unmounting."""
self._stop_following()
+ if self._status_task:
+ self._status_task.cancel()
+ self._status_task = None
async def _load_logs(self, lines: int = 200) -> None:
"""Load recent logs for the current service."""
@@ -235,6 +260,10 @@ class LogsScreen(Screen):
"""Clear the logs area."""
self.logs_area.text = ""
+ def action_copy_logs(self) -> None:
+ """Copy log content to the clipboard."""
+ self._copy_logs_to_clipboard()
+
def action_toggle_auto_scroll(self) -> None:
"""Toggle auto scroll on/off."""
self.auto_scroll = not self.auto_scroll
@@ -284,3 +313,44 @@ class LogsScreen(Screen):
"""Go back to previous screen."""
self._stop_following()
self.app.pop_screen()
+
+ def _copy_logs_to_clipboard(self) -> None:
+ """Copy the current log buffer to the clipboard."""
+ if not self.logs_area:
+ return
+
+ content = self.logs_area.text or ""
+ status_widget = self.query_one("#copy-status", Static)
+
+ if not content.strip():
+ message = "No logs to copy"
+ self.notify(message, severity="warning")
+ status_widget.update(Text("⚠ No logs to copy", style="bold yellow"))
+ self._schedule_status_clear(status_widget)
+ return
+
+ success, message = copy_text_to_clipboard(content)
+ self.notify(message, severity="information" if success else "error")
+ prefix = "✓" if success else "❌"
+ style = "bold green" if success else "bold red"
+ status_widget.update(Text(f"{prefix} {message}", style=style))
+ self._schedule_status_clear(status_widget)
+
+ def on_button_pressed(self, event: Button.Pressed) -> None:
+ """Handle button presses."""
+ if event.button.id == "copy-btn":
+ self._copy_logs_to_clipboard()
+
+ def _schedule_status_clear(self, widget: Static, delay: float = 3.0) -> None:
+ """Clear the status message after a short 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())
diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py
index 372abc27..ba8e6659 100644
--- a/src/tui/screens/monitor.py
+++ b/src/tui/screens/monitor.py
@@ -145,11 +145,7 @@ class MonitorScreen(Screen):
# Set up auto-refresh every 5 seconds
self.refresh_timer = self.set_interval(5.0, self._auto_refresh)
- # Focus the services table
- try:
- self.services_table.focus()
- except Exception:
- pass
+ self._focus_services_table()
def on_unmount(self) -> None:
"""Clean up when unmounting."""
@@ -224,6 +220,9 @@ class MonitorScreen(Screen):
docling_pid,
"Start/Stop/Logs"
)
+ # Restore docling selection when it was the last active table
+ if self._last_selected_table == "docling":
+ self._focus_docling_table(focus=False, set_last=False)
# Populate images table (unique images as reported by runtime)
if self.images_table:
for image in sorted(images):
@@ -509,16 +508,52 @@ class MonitorScreen(Screen):
self.run_worker(self._refresh_services())
def action_cursor_down(self) -> None:
- """Move cursor down in services table."""
+ """Move selection down, handling both tables."""
+ active_table = self._get_active_table_name()
+
try:
- self.services_table.action_cursor_down()
+ if active_table == "docling":
+ return # Nothing to move within docling table
+
+ if not self.services_table:
+ return
+
+ row_count = self._table_row_count(self.services_table)
+ current = self._get_cursor_row(self.services_table)
+ if current is None:
+ current = 0
+
+ if current < row_count - 1:
+ self.services_table.action_cursor_down()
+ self._last_selected_table = "services"
+ elif self._table_row_count(self.docling_table):
+ self._focus_docling_table()
except Exception:
pass
def action_cursor_up(self) -> None:
- """Move cursor up in services table."""
+ """Move selection up, handling both tables."""
+ active_table = self._get_active_table_name()
+
try:
- self.services_table.action_cursor_up()
+ if active_table == "docling":
+ self._focus_services_table(row="last")
+ return
+
+ if not self.services_table:
+ return
+
+ current = self._get_cursor_row(self.services_table)
+ if current is None:
+ current = 0
+
+ if current > 0:
+ self.services_table.action_cursor_up()
+ else:
+ # Already at the top; nothing else to do
+ self._set_cursor_row(self.services_table, 0)
+
+ self._last_selected_table = "services"
except Exception:
pass
@@ -664,59 +699,37 @@ class MonitorScreen(Screen):
self.notify(f"Error opening logs: {e}", severity="error")
def _get_selected_service(self) -> str | None:
- """Get the currently selected service from either table."""
+ """Resolve the currently selected service based on active table."""
try:
- # Check both tables regardless of last_selected_table to handle cursor navigation
- services_table = self.query_one("#services-table", DataTable)
- services_cursor = services_table.cursor_row
+ active_table = self._get_active_table_name()
- docling_cursor = None
- if self.docling_table:
- docling_cursor = self.docling_table.cursor_row
-
- # If we have a last selected table preference, use it if that table has a valid selection
- if self._last_selected_table == "docling" and self.docling_table:
- if docling_cursor is not None and docling_cursor >= 0:
- row_data = self.docling_table.get_row_at(docling_cursor)
- if row_data:
- return "docling-serve"
-
- elif self._last_selected_table == "services":
- if services_cursor is not None and services_cursor >= 0:
- row_data = services_table.get_row_at(services_cursor)
- if row_data:
- service_name = str(row_data[0])
- service_mapping = {
- "openrag-backend": "openrag-backend",
- "openrag-frontend": "openrag-frontend",
- "opensearch": "opensearch",
- "langflow": "langflow",
- "dashboards": "dashboards",
- }
- selected_service = service_mapping.get(service_name, service_name)
- return selected_service
-
- # Fallback: check both tables if no last_selected_table or it doesn't have a selection
- if self.docling_table and docling_cursor is not None and docling_cursor >= 0:
- row_data = self.docling_table.get_row_at(docling_cursor)
- if row_data:
+ if active_table == "docling" and self.docling_table:
+ cursor = self._get_cursor_row(self.docling_table)
+ if cursor is not None and cursor >= 0:
return "docling-serve"
- if services_cursor is not None and services_cursor >= 0:
- row_data = services_table.get_row_at(services_cursor)
- if row_data:
- service_name = str(row_data[0])
- service_mapping = {
- "openrag-backend": "openrag-backend",
- "openrag-frontend": "openrag-frontend",
- "opensearch": "opensearch",
- "langflow": "langflow",
- "dashboards": "dashboards",
- }
- selected_service = service_mapping.get(service_name, service_name)
- return selected_service
+ services_table = self.query_one("#services-table", DataTable)
+ row_count = self._table_row_count(services_table)
+ if row_count == 0:
+ return None
- return None
+ cursor = self._get_cursor_row(services_table)
+ if cursor is None or cursor < 0 or cursor >= row_count:
+ cursor = 0
+
+ row_data = services_table.get_row_at(cursor)
+ if not row_data:
+ return None
+
+ service_name = str(row_data[0])
+ service_mapping = {
+ "openrag-backend": "openrag-backend",
+ "openrag-frontend": "openrag-frontend",
+ "opensearch": "opensearch",
+ "langflow": "langflow",
+ "dashboards": "dashboards",
+ }
+ return service_mapping.get(service_name, service_name)
except Exception as e:
self.notify(f"Error getting selected service: {e}", severity="error")
return None
@@ -728,15 +741,118 @@ class MonitorScreen(Screen):
try:
# Track which table was selected
if selected_table.id == "services-table":
- self._last_selected_table = "services"
- # Clear docling table selection
- if self.docling_table:
- self.docling_table.cursor_row = -1
+ self._focus_services_table(row="current")
elif selected_table.id == "docling-table":
- self._last_selected_table = "docling"
- # Clear services table selection
- services_table = self.query_one("#services-table", DataTable)
- services_table.cursor_row = -1
+ self._focus_docling_table()
except Exception:
# Ignore errors during table manipulation
pass
+
+ def _get_active_table_name(self) -> str:
+ """Determine which table is currently active."""
+ if self.docling_table and self.docling_table.has_focus:
+ return "docling"
+ if self.services_table and self.services_table.has_focus:
+ return "services"
+ return self._last_selected_table or "services"
+
+ def _table_row_count(self, table: DataTable | None) -> int:
+ """Safely compute the number of rows in a DataTable."""
+ if not table:
+ return 0
+
+ count_attr = getattr(table, "row_count", None)
+ if callable(count_attr):
+ try:
+ return int(count_attr())
+ except Exception:
+ pass
+
+ if isinstance(count_attr, int):
+ return count_attr
+
+ try:
+ rows = getattr(table, "rows", None)
+ if rows is not None:
+ return len(rows)
+ except Exception:
+ pass
+
+ return 0
+
+ def _get_cursor_row(self, table: DataTable | None) -> int | None:
+ """Return the current cursor row for the given table."""
+ if not table:
+ return None
+
+ coord = getattr(table, "cursor_coordinate", None)
+ if coord is None:
+ return None
+
+ row = getattr(coord, "row", None)
+ if row is not None:
+ return row
+
+ if isinstance(coord, tuple) and coord:
+ return coord[0]
+
+ return None
+
+ def _set_cursor_row(self, table: DataTable | None, row: int) -> None:
+ """Set the cursor row for the given table, if possible."""
+ if not table:
+ return
+
+ try:
+ table.cursor_coordinate = (row, 0)
+ except Exception:
+ move_cursor = getattr(table, "move_cursor", None)
+ if callable(move_cursor):
+ try:
+ move_cursor(row, 0, expand=False)
+ except Exception:
+ pass
+
+ def _focus_services_table(self, row: str | None = None, set_last: bool = True) -> None:
+ """Focus the services table and update selection."""
+ if not self.services_table:
+ return
+
+ try:
+ self.services_table.focus()
+ row_count = self._table_row_count(self.services_table)
+
+ if row_count:
+ if row == "last":
+ self._set_cursor_row(self.services_table, row_count - 1)
+ elif row == "current":
+ # Keep existing cursor position if valid
+ cursor = self._get_cursor_row(self.services_table)
+ if cursor is None or cursor < 0 or cursor >= row_count:
+ self._set_cursor_row(self.services_table, 0)
+ else:
+ cursor = self._get_cursor_row(self.services_table)
+ if cursor is None or cursor < 0:
+ self._set_cursor_row(self.services_table, 0)
+
+ if set_last:
+ self._last_selected_table = "services"
+ except Exception:
+ pass
+
+ def _focus_docling_table(self, focus: bool = True, set_last: bool = True) -> None:
+ """Focus the docling table and select its row."""
+ if not self.docling_table:
+ return
+
+ try:
+ if focus:
+ self.docling_table.focus()
+
+ if self._table_row_count(self.docling_table):
+ self._set_cursor_row(self.docling_table, 0)
+
+ if set_last:
+ self._last_selected_table = "docling"
+ except Exception:
+ pass
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
diff --git a/uv.lock b/uv.lock
index 853b6b23..0a60fd52 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.13"
resolution-markers = [
"sys_platform == 'darwin'",
@@ -2282,7 +2282,7 @@ wheels = [
[[package]]
name = "openrag"
-version = "0.1.3"
+version = "0.1.8"
source = { editable = "." }
dependencies = [
{ name = "agentd" },