diff --git a/haiku_rag_slim/haiku/rag/inspector/app.py b/haiku_rag_slim/haiku/rag/inspector/app.py index 4a5f4366..4141ce5c 100644 --- a/haiku_rag_slim/haiku/rag/inspector/app.py +++ b/haiku_rag_slim/haiku/rag/inspector/app.py @@ -11,12 +11,12 @@ if TYPE_CHECKING: try: from textual.app import App from textual.binding import Binding - from textual.containers import Container - from textual.widgets import Footer, Header, Input, ListItem, Static + from textual.widgets import Footer, Header from haiku.rag.inspector.widgets.chunk_list import ChunkList from haiku.rag.inspector.widgets.detail_view import DetailView from haiku.rag.inspector.widgets.document_list import DocumentList + from haiku.rag.inspector.widgets.search_modal import SearchModal TEXTUAL_AVAILABLE = True except ImportError: @@ -63,21 +63,6 @@ class InspectorApp(App): # type: ignore[misc] overflow: hidden; text-overflow: ellipsis; } - - #search-container { - dock: top; - height: 3; - background: $panel; - display: none; - } - - #search-container.visible { - display: block; - } - - #search-input { - width: 1fr; - } """ BINDINGS = [ @@ -89,17 +74,10 @@ class InspectorApp(App): # type: ignore[misc] super().__init__() self.db_path = db_path self.client: HaikuRAG | None = None - self.search_visible = False - self.search_active = False - # Track current selection for easy restoration - self.current_document_id: str | None = None - self.current_chunk_id: str | None = None def compose(self) -> "ComposeResult": """Compose the UI layout.""" yield Header() - with Container(id="search-container"): - yield Input(placeholder="Search chunks...", id="search-input") yield DocumentList(id="document-list") yield ChunkList(id="chunk-list") yield DetailView(id="detail-view") @@ -108,28 +86,13 @@ class InspectorApp(App): # type: ignore[misc] async def on_mount(self) -> None: """Initialize the app when mounted.""" config = get_config() - self.client = HaikuRAG(db_path=self.db_path, config=config, allow_create=False) + self.client = HaikuRAG(db_path=self.db_path, config=config, read_only=True) await self.client.__aenter__() # Load initial documents doc_list = self.query_one(DocumentList) await doc_list.load_documents(self.client) - # Select first document and load its chunks - if doc_list.documents and doc_list.list_view: - doc_list.list_view.index = 0 - first_doc = doc_list.documents[0] - self.current_document_id = first_doc.id - - if first_doc.id: - chunk_list = self.query_one(ChunkList) - await chunk_list.load_chunks_for_document(self.client, first_doc.id) - - # Select first chunk - if chunk_list.chunks and chunk_list.list_view: - chunk_list.list_view.index = 0 - self.current_chunk_id = chunk_list.chunks[0].id - # Focus the document list view if doc_list.list_view: doc_list.list_view.focus() @@ -139,107 +102,50 @@ class InspectorApp(App): # type: ignore[misc] if self.client: await self.client.__aexit__(None, None, None) + def _select_chunk(self, chunk_list: ChunkList, chunk_id: str) -> None: + """Helper to select a chunk after refresh.""" + for idx, c in enumerate(chunk_list.chunks): + if c.id == chunk_id: + if chunk_list.list_view: + chunk_list.list_view.index = idx + chunk_list.list_view.focus() + break + async def action_search(self) -> None: - """Toggle search input visibility.""" - search_container = self.query_one("#search-container", Container) - search_input = self.query_one("#search-input", Input) + """Open search modal.""" + if self.client: + await self.push_screen(SearchModal(self.client)) - if self.search_visible: - search_container.remove_class("visible") - search_input.value = "" - self.search_visible = False - self.search_active = False + async def on_search_modal_chunk_selected( + self, message: SearchModal.ChunkSelected + ) -> None: + """Handle chunk selection from search modal.""" + if not self.client: + return - # Restore full document list and reload chunks for selected document - if self.client: + chunk = message.chunk + + # Navigate to the document containing this chunk + if chunk.document_id: + doc = await self.client.document_repository.get_by_id(chunk.document_id) + if doc: doc_list = self.query_one(DocumentList) chunk_list = self.query_one(ChunkList) - # Reload all documents - await doc_list.load_documents(self.client) - - # Restore document and chunk selection - if ( - self.current_document_id - and doc_list.list_view - and doc_list.documents - ): - # Find and select the document - doc_found = False - for idx, doc in enumerate(doc_list.documents): - if doc.id == self.current_document_id: + # Find and select the document + for idx, d in enumerate(doc_list.documents): + if d.id == chunk.document_id: + if doc_list.list_view: doc_list.list_view.index = idx - doc_found = True - break + break - # Reload chunks for this document (without scores) - if doc_found: - await chunk_list.load_chunks_for_document( - self.client, self.current_document_id - ) - - # Restore chunk selection and show it in detail view - if ( - self.current_chunk_id - and chunk_list.list_view - and chunk_list.chunks - ): - for idx, chunk in enumerate(chunk_list.chunks): - if chunk.id == self.current_chunk_id: - chunk_list.list_view.index = idx - # Show the chunk in detail view - detail_view = self.query_one(DetailView) - await detail_view.show_chunk(chunk) - break - - # Focus back to document list to show selection - doc_list.list_view.focus() - else: - search_container.add_class("visible") - search_input.focus() - self.search_visible = True - - async def on_input_submitted(self, event: Input.Submitted) -> None: - """Handle search input submission.""" - if event.input.id == "search-input" and self.client: - query = event.value.strip() - if query: - self.search_active = True - chunk_list = self.query_one(ChunkList) - await chunk_list.load_chunks_from_search(self.client, query) - - # Get unique document IDs from the search results - doc_ids = list( - { - chunk.document_id - for chunk in chunk_list.chunks - if chunk.document_id - } + # Load chunks for this document + await chunk_list.load_chunks_for_document( + self.client, chunk.document_id ) - # Update document list to show only documents with matching chunks - if doc_ids: - doc_list = self.query_one(DocumentList) - documents = [] - for doc_id in doc_ids: - doc = await self.client.document_repository.get_by_id(doc_id) - if doc: - documents.append(doc) - - # Update the document list with filtered documents - doc_list.documents = documents - if doc_list.list_view: - await doc_list.list_view.clear() - for doc in documents: - title = doc.title or doc.uri or doc.id or "Untitled" - item = ListItem(Static(f"{title}")) - await doc_list.list_view.append(item) - - # Select first chunk and focus the chunk list - if chunk_list.list_view: - if chunk_list.chunks: - chunk_list.list_view.index = 0 - chunk_list.list_view.focus() + # Wait a tick for the ListView to process the new items + self.call_after_refresh(self._select_chunk, chunk_list, chunk.id) async def on_document_list_document_selected( self, message: DocumentList.DocumentSelected @@ -252,15 +158,12 @@ class InspectorApp(App): # type: ignore[misc] if not self.client: return - # Always track current document (even during search) - self.current_document_id = message.document.id - # Show document details detail_view = self.query_one(DetailView) await detail_view.show_document(message.document) - # Load chunks for this document (but not during search - preserve search results) - if message.document.id and not self.search_active: + # Load chunks for this document + if message.document.id: chunk_list = self.query_one(ChunkList) await chunk_list.load_chunks_for_document(self.client, message.document.id) @@ -272,17 +175,10 @@ class InspectorApp(App): # type: ignore[misc] Args: message: Message containing selected chunk """ - # Always track current chunk (even during search) - self.current_chunk_id = message.chunk.id - # Show chunk details detail_view = self.query_one(DetailView) await detail_view.show_chunk(message.chunk) - # Track the document this chunk belongs to - if message.chunk.document_id: - self.current_document_id = message.chunk.document_id - def run_inspector(db_path: Path | None = None) -> None: """Run the inspector TUI. diff --git a/haiku_rag_slim/haiku/rag/inspector/widgets/chunk_list.py b/haiku_rag_slim/haiku/rag/inspector/widgets/chunk_list.py index 9bb5b834..420f3c31 100644 --- a/haiku_rag_slim/haiku/rag/inspector/widgets/chunk_list.py +++ b/haiku_rag_slim/haiku/rag/inspector/widgets/chunk_list.py @@ -1,3 +1,4 @@ +from textual import on from textual.app import ComposeResult from textual.containers import VerticalScroll from textual.message import Message @@ -53,43 +54,12 @@ class ChunkList(VerticalScroll): item = ListItem(Static(f"[{chunk.order}] {first_line}")) await self.list_view.append(item) - async def load_chunks_from_search( - self, client: HaikuRAG, query: str, limit: int = 20 + @on(ListView.Highlighted) + @on(ListView.Selected) + async def handle_chunk_selection( + self, event: ListView.Highlighted | ListView.Selected ) -> None: - """Load chunks from search results. - - Args: - client: HaikuRAG client instance - query: Search query - limit: Maximum number of results - """ - if self.list_view is None: - return - - results = await client.chunk_repository.search( - query=query, limit=limit, search_type="hybrid" - ) - self.chunks = [chunk for chunk, _score in results] - - # Clear existing items - await self.list_view.clear() - - # Add chunk items with scores - for chunk, score in results: - first_line = chunk.content.split("\n")[0] - item = ListItem(Static(f"[{score:.2f}] {first_line}")) - await self.list_view.append(item) - - async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: - """Handle chunk navigation (arrow keys).""" - if event.list_view == self.list_view and event.item is not None: - idx = event.list_view.index - if idx is not None and 0 <= idx < len(self.chunks): - chunk = self.chunks[idx] - self.post_message(self.ChunkSelected(chunk)) - - async def on_list_view_selected(self, event: ListView.Selected) -> None: - """Handle chunk selection (Enter key).""" + """Handle chunk selection (arrow keys or Enter).""" if event.list_view == self.list_view: idx = event.list_view.index if idx is not None and 0 <= idx < len(self.chunks): diff --git a/haiku_rag_slim/haiku/rag/inspector/widgets/detail_view.py b/haiku_rag_slim/haiku/rag/inspector/widgets/detail_view.py index 551b85c3..c599075d 100644 --- a/haiku_rag_slim/haiku/rag/inspector/widgets/detail_view.py +++ b/haiku_rag_slim/haiku/rag/inspector/widgets/detail_view.py @@ -8,7 +8,7 @@ from haiku.rag.store.models import Chunk, Document class DetailView(VerticalScroll): """Widget for displaying detailed content of documents or chunks.""" - can_focus = False + can_focus = True def __init__(self, **kwargs) -> None: super().__init__(**kwargs) diff --git a/haiku_rag_slim/haiku/rag/inspector/widgets/document_list.py b/haiku_rag_slim/haiku/rag/inspector/widgets/document_list.py index 8d33895a..4d8de6a9 100644 --- a/haiku_rag_slim/haiku/rag/inspector/widgets/document_list.py +++ b/haiku_rag_slim/haiku/rag/inspector/widgets/document_list.py @@ -1,3 +1,4 @@ +from textual import on from textual.app import ComposeResult from textual.containers import VerticalScroll from textual.message import Message @@ -50,16 +51,12 @@ class DocumentList(VerticalScroll): item = ListItem(Static(f"{title}")) await self.list_view.append(item) - async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: - """Handle document navigation (arrow keys).""" - if event.list_view == self.list_view and event.item is not None: - idx = event.list_view.index - if idx is not None and 0 <= idx < len(self.documents): - document = self.documents[idx] - self.post_message(self.DocumentSelected(document)) - - async def on_list_view_selected(self, event: ListView.Selected) -> None: - """Handle document selection (Enter key).""" + @on(ListView.Highlighted) + @on(ListView.Selected) + async def handle_document_selection( + self, event: ListView.Highlighted | ListView.Selected + ) -> None: + """Handle document selection (arrow keys or Enter).""" if event.list_view == self.list_view: idx = event.list_view.index if idx is not None and 0 <= idx < len(self.documents): diff --git a/haiku_rag_slim/haiku/rag/inspector/widgets/search_modal.py b/haiku_rag_slim/haiku/rag/inspector/widgets/search_modal.py new file mode 100644 index 00000000..7ef8e256 --- /dev/null +++ b/haiku_rag_slim/haiku/rag/inspector/widgets/search_modal.py @@ -0,0 +1,151 @@ +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.message import Message +from textual.screen import Screen +from textual.widgets import Input, ListItem, ListView, Static + +from haiku.rag.client import HaikuRAG +from haiku.rag.inspector.widgets.detail_view import DetailView +from haiku.rag.store.models import Chunk + + +class SearchModal(Screen): + """Screen for searching chunks.""" + + BINDINGS = [ + Binding("escape", "dismiss", "Close", show=True), + ] + + CSS = """ + SearchModal { + background: $surface; + layout: vertical; + } + + #search-header { + dock: top; + height: auto; + } + + #search-content { + height: 1fr; + width: 100%; + } + + #search-results-container { + width: 1fr; + border: solid $primary; + } + + #search-detail { + width: 2fr; + border: solid $accent; + } + + ListItem { + overflow: hidden; + } + + ListItem Static { + overflow: hidden; + text-overflow: ellipsis; + } + """ + + class ChunkSelected(Message): + """Message sent when a chunk is selected from search results.""" + + def __init__(self, chunk: Chunk) -> None: + super().__init__() + self.chunk = chunk + + def __init__(self, client: HaikuRAG): + super().__init__() + self.client = client + self.chunks: list[Chunk] = [] + + def compose(self) -> ComposeResult: + """Compose the search screen.""" + with Vertical(id="search-header"): + yield Static("[bold]Search Chunks[/bold]") + yield Input(placeholder="Enter search query...", id="search-input") + yield Static("", id="status-label") + with Horizontal(id="search-content"): + with VerticalScroll(id="search-results-container"): + yield ListView(id="search-results") + yield DetailView(id="search-detail") + + async def on_mount(self) -> None: + """Focus the search input when mounted.""" + status_label = self.query_one("#status-label", Static) + status_label.update("Type query and press Enter to search") + search_input = self.query_one("#search-input", Input) + search_input.focus() + + @on(Input.Submitted, "#search-input") + async def search_submitted(self, event: Input.Submitted) -> None: + """Handle search query submission.""" + query = event.value.strip() + if query: + await self.run_search(query) + + async def run_search(self, query: str) -> None: + """Perform the search.""" + status_label = self.query_one("#status-label", Static) + list_view = self.query_one("#search-results", ListView) + + status_label.update("Searching...") + + try: + # Perform search + results = await self.client.chunk_repository.search( + query=query, limit=50, search_type="hybrid" + ) + + self.chunks = [chunk for chunk, _score in results] + + # Clear and populate results + await list_view.clear() + for chunk, score in results: + first_line = chunk.content.split("\n")[0] + score_str = f"{score:.2f}" if score else "N/A" + item = ListItem(Static(f"[{score_str}] {first_line}")) + await list_view.append(item) + + # Update status + status_label.update(f"Found {len(self.chunks)} results") + + # Select first result, show in detail view, and focus list + if self.chunks: + list_view.index = 0 + detail_view = self.query_one("#search-detail", DetailView) + await detail_view.show_chunk(self.chunks[0]) + list_view.focus() + except Exception as e: + status_label.update(f"Error: {str(e)}") + + async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + """Handle chunk navigation (arrow keys).""" + list_view = self.query_one("#search-results", ListView) + if event.list_view == list_view and event.item is not None: + idx = event.list_view.index + if idx is not None and 0 <= idx < len(self.chunks): + chunk = self.chunks[idx] + detail_view = self.query_one("#search-detail", DetailView) + await detail_view.show_chunk(chunk) + + async def on_list_view_selected(self, event: ListView.Selected) -> None: + """Handle chunk selection (Enter key).""" + list_view = self.query_one("#search-results", ListView) + if event.list_view == list_view: + idx = event.list_view.index + if idx is not None and 0 <= idx < len(self.chunks): + chunk = self.chunks[idx] + self.post_message(self.ChunkSelected(chunk)) + self.app.pop_screen() + + async def action_dismiss(self, result=None) -> None: + """Close the search screen.""" + self.app.pop_screen()