Make search a screen

This commit is contained in:
Yiorgis Gozadinos 2025-11-21 17:06:30 +02:00
parent 0d126d894e
commit 2c6efc55c6
No known key found for this signature in database
5 changed files with 204 additions and 190 deletions

View file

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

View file

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

View file

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

View file

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

View file

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