Files
rag-manager/ingest_pipeline/cli/tui/app.py
2025-09-18 09:44:16 +00:00

312 lines
11 KiB
Python

"""Main TUI application with enhanced keyboard navigation."""
from __future__ import annotations
import logging
import os
from collections import deque
from pathlib import Path
from queue import Empty, Queue
from typing import TYPE_CHECKING, ClassVar, Literal
from textual import events
from textual.app import App
from textual.binding import Binding, BindingType
from textual.timer import Timer
from ...storage.base import BaseStorage
from ...storage.openwebui import OpenWebUIStorage
from ...storage.weaviate import WeaviateStorage
from .screens import CollectionOverviewScreen, HelpScreen
from .styles import TUI_CSS
from .utils.storage_manager import StorageManager
if TYPE_CHECKING:
from logging import Formatter, LogRecord
from ...storage.r2r.storage import R2RStorage
from .screens.dialogs import LogViewerScreen
else: # pragma: no cover - optional dependency fallback
R2RStorage = BaseStorage
class CollectionManagementApp(App[None]):
"""Enhanced modern Textual application with comprehensive keyboard navigation."""
CSS: ClassVar[str] = TUI_CSS
def safe_notify(
self,
message: str,
*,
severity: Literal["information", "warning", "error"] = "information",
) -> None:
"""Safely notify with markup disabled to prevent parsing errors."""
self.notify(message, severity=severity, markup=False)
BINDINGS: ClassVar[list[BindingType]] = [
Binding("q", "quit", "Quit"),
Binding("ctrl+c", "quit", "Quit"),
Binding("ctrl+q", "quit", "Quit"),
Binding("f1", "help", "Help"),
Binding("ctrl+h", "help", "Help"),
Binding("?", "help", "Quick Help"),
# Global navigation shortcuts
Binding("ctrl+r", "refresh_current", "Refresh Current Screen"),
Binding("ctrl+w", "close_current", "Close Current Screen"),
Binding("ctrl+l", "toggle_logs", "Logs"),
# Tab navigation shortcuts
Binding("ctrl+1", "dashboard_tab", "Dashboard", show=False),
Binding("ctrl+2", "collections_tab", "Collections", show=False),
Binding("ctrl+3", "analytics_tab", "Analytics", show=False),
]
storage_manager: StorageManager
weaviate: WeaviateStorage | None
openwebui: OpenWebUIStorage | None
r2r: R2RStorage | BaseStorage | None
log_queue: Queue[LogRecord] | None
_log_formatter: Formatter
_log_buffer: deque[str]
_log_viewer: LogViewerScreen | None
_log_file: Path | None
_log_timer: Timer | None
def __init__(
self,
storage_manager: StorageManager,
weaviate: WeaviateStorage | None = None,
openwebui: OpenWebUIStorage | None = None,
r2r: R2RStorage | BaseStorage | None = None,
*,
log_queue: Queue[LogRecord] | None = None,
log_formatter: Formatter | None = None,
log_file: Path | None = None,
) -> None:
super().__init__()
self.storage_manager = storage_manager
self.weaviate = weaviate
self.openwebui = openwebui
self.r2r = r2r
self.title: str = ""
self.sub_title: str = ""
self.log_queue = log_queue
self._log_formatter = log_formatter or logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%H:%M:%S",
)
self._log_buffer = deque(maxlen=500)
self._log_viewer = None
self._log_file = log_file
self._log_timer = None
def on_mount(self) -> None:
"""Initialize the enhanced app with better branding."""
self.title = "🚀 Enhanced Collection Management System"
self.sub_title = (
"Advanced Document Ingestion & Management Platform with Keyboard Navigation"
)
reduced_motion_env = os.getenv("TEXTUAL_REDUCED_MOTION") or os.getenv(
"PREFER_REDUCED_MOTION"
)
if reduced_motion_env is not None:
normalized = reduced_motion_env.strip().lower()
reduced_motion_enabled = normalized in {"1", "true", "yes", "on"}
else:
reduced_motion_enabled = False
_ = self.set_class(reduced_motion_enabled, "reduced-motion")
_ = self.push_screen(
CollectionOverviewScreen(
self.storage_manager,
self.weaviate,
self.openwebui,
self.r2r,
)
)
if self.log_queue is not None and self._log_timer is None:
# Poll the queue so log output is captured without blocking the UI loop
self._log_timer = self.set_interval(0.25, self._drain_log_queue)
def _drain_log_queue(self) -> None:
"""Drain queued log records and route them to the active log viewer."""
if self.log_queue is None:
return
drained: list[str] = []
while True:
try:
record = self.log_queue.get_nowait()
except Empty:
break
message = self._log_formatter.format(record)
self._log_buffer.append(message)
drained.append(message)
if drained and self._log_viewer is not None:
self._log_viewer.append_logs(drained)
def attach_log_viewer(self, viewer: "LogViewerScreen") -> None:
"""Register an active log viewer and hydrate it with existing entries."""
self._log_viewer = viewer
viewer.replace_logs(list(self._log_buffer))
viewer.update_log_file(self._log_file)
# Drain once more to deliver any entries gathered between instantiation and mount
self._drain_log_queue()
def detach_log_viewer(self, viewer: "LogViewerScreen") -> None:
"""Remove the current log viewer when it is dismissed."""
if self._log_viewer is viewer:
self._log_viewer = None
def get_log_file_path(self) -> Path | None:
"""Return the active log file path if configured."""
return self._log_file
def action_toggle_logs(self) -> None:
"""Toggle the log viewer modal screen."""
if self._log_viewer is not None:
_ = self.pop_screen()
return
from .screens.dialogs import LogViewerScreen # Local import to avoid cycle
_ = self.push_screen(LogViewerScreen())
def action_help(self) -> None:
"""Show comprehensive help information with all keyboard shortcuts."""
help_md = """
# 🚀 Enhanced Collection Management System
## 🎯 Global Navigation
- **F1** / **Ctrl+H** / **?**: Show this help
- **Q** / **Ctrl+C** / **Ctrl+Q**: Quit application
- **Ctrl+R**: Refresh current screen
- **Ctrl+W**: Close current screen/dialog
- **Escape**: Go back/cancel current action
## 📑 Tab Navigation
- **Tab** / **Shift+Tab**: Switch between tabs
- **Ctrl+1**: Jump to Dashboard tab
- **Ctrl+2**: Jump to Collections tab
- **Ctrl+3**: Jump to Analytics tab
## 📚 Collections Management
- **R**: Refresh collections list
- **I**: Start new ingestion
- **M**: Manage documents in selected collection
- **S**: Search within selected collection
- **Ctrl+D**: Delete selected collection
## 🗂️ Table Navigation
- **Arrow Keys** / **J/K/H/L**: Navigate table cells (Vi-style)
- **Home** / **End**: Jump to first/last row
- **Page Up** / **Page Down**: Scroll by page
- **Enter**: Select/activate current row
- **Space**: Toggle row selection
- **Ctrl+A**: Select all items
- **Ctrl+Shift+A**: Clear all selections
## 📄 Document Management
- **Space**: Toggle document selection
- **Delete** / **Ctrl+D**: Delete selected documents
- **A**: Select all documents on page
- **N**: Clear selection
- **Page Up/Down**: Navigate between pages
- **Home/End**: Go to first/last page
## 🔍 Search Features
- **/** : Quick search (focus search field)
- **Ctrl+F**: Focus search input
- **Enter**: Perform search
- **F3**: Repeat last search
- **Ctrl+R**: Clear search results
- **Escape**: Clear search/exit search mode
## 📥 Ingestion Interface
- **1/2/3**: Select ingestion type (Web/Repository/Documentation)
- **Tab/Shift+Tab**: Navigate between fields
- **Enter**: Start ingestion process
- **Ctrl+I**: Quick start ingestion
- **Escape**: Cancel ingestion
## 🎨 Visual Features
- Enhanced focus indicators with colored borders
- Smooth keyboard navigation with visual feedback
- Status indicators with real-time updates
- Progress bars with detailed status messages
- Responsive design with accessibility features
## 💡 Pro Tips
- Use **Vi-style** navigation (J/K/H/L) for efficient movement
- **Tab** through interactive elements for keyboard-only operation
- Hold **Shift** with arrow keys for range selection (where supported)
- Use **Ctrl+** shortcuts for power user efficiency
- **Escape** is your friend - it cancels most operations safely
## 🚀 Performance Features
- Lazy loading for large collections
- Paginated document views
- Background refresh operations
- Efficient memory management
- Responsive UI updates
---
**Enjoy the enhanced keyboard-driven interface!** 🎉
*Press Escape, Enter, or Q to close this help.*
"""
_ = self.push_screen(HelpScreen(help_md))
def action_refresh_current(self) -> None:
"""Refresh the current screen if it supports it."""
current_screen = self.screen
handler = getattr(current_screen, "action_refresh", None)
if callable(handler):
_ = handler()
return
self.notify("Current screen doesn't support refresh", severity="information")
def action_close_current(self) -> None:
"""Close current screen/dialog."""
if len(self.screen_stack) > 1: # Don't close the main screen
_ = self.pop_screen()
else:
_ = self.notify("Cannot close main screen. Use Q to quit.", severity="warning")
def action_dashboard_tab(self) -> None:
"""Switch to dashboard tab in current screen."""
current_screen = self.screen
handler = getattr(current_screen, "action_tab_dashboard", None)
if callable(handler):
_ = handler()
def action_collections_tab(self) -> None:
"""Switch to collections tab in current screen."""
current_screen = self.screen
handler = getattr(current_screen, "action_tab_collections", None)
if callable(handler):
_ = handler()
def action_analytics_tab(self) -> None:
"""Switch to analytics tab in current screen."""
current_screen = self.screen
handler = getattr(current_screen, "action_tab_analytics", None)
if callable(handler):
_ = handler()
def on_key(self, event: events.Key) -> None:
"""Handle global keyboard shortcuts."""
# Handle global shortcuts that might not be bound to specific actions
if event.key == "ctrl+shift+?":
# Alternative help shortcut
self.action_help()
_ = event.prevent_default()
elif event.key == "ctrl+alt+r":
# Force refresh all connections
_ = self.notify("🔄 Refreshing all connections...", severity="information")
# This could trigger a full reinit if needed
_ = event.prevent_default()
# No else clause needed - just handle our events