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: try:
from textual.app import App from textual.app import App
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Container from textual.widgets import Footer, Header
from textual.widgets import Footer, Header, Input, ListItem, Static
from haiku.rag.inspector.widgets.chunk_list import ChunkList from haiku.rag.inspector.widgets.chunk_list import ChunkList
from haiku.rag.inspector.widgets.detail_view import DetailView from haiku.rag.inspector.widgets.detail_view import DetailView
from haiku.rag.inspector.widgets.document_list import DocumentList from haiku.rag.inspector.widgets.document_list import DocumentList
from haiku.rag.inspector.widgets.search_modal import SearchModal
TEXTUAL_AVAILABLE = True TEXTUAL_AVAILABLE = True
except ImportError: except ImportError:
@ -63,21 +63,6 @@ class InspectorApp(App): # type: ignore[misc]
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
#search-container {
dock: top;
height: 3;
background: $panel;
display: none;
}
#search-container.visible {
display: block;
}
#search-input {
width: 1fr;
}
""" """
BINDINGS = [ BINDINGS = [
@ -89,17 +74,10 @@ class InspectorApp(App): # type: ignore[misc]
super().__init__() super().__init__()
self.db_path = db_path self.db_path = db_path
self.client: HaikuRAG | None = None 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": def compose(self) -> "ComposeResult":
"""Compose the UI layout.""" """Compose the UI layout."""
yield Header() yield Header()
with Container(id="search-container"):
yield Input(placeholder="Search chunks...", id="search-input")
yield DocumentList(id="document-list") yield DocumentList(id="document-list")
yield ChunkList(id="chunk-list") yield ChunkList(id="chunk-list")
yield DetailView(id="detail-view") yield DetailView(id="detail-view")
@ -108,28 +86,13 @@ class InspectorApp(App): # type: ignore[misc]
async def on_mount(self) -> None: async def on_mount(self) -> None:
"""Initialize the app when mounted.""" """Initialize the app when mounted."""
config = get_config() 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__() await self.client.__aenter__()
# Load initial documents # Load initial documents
doc_list = self.query_one(DocumentList) doc_list = self.query_one(DocumentList)
await doc_list.load_documents(self.client) 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 # Focus the document list view
if doc_list.list_view: if doc_list.list_view:
doc_list.list_view.focus() doc_list.list_view.focus()
@ -139,107 +102,50 @@ class InspectorApp(App): # type: ignore[misc]
if self.client: if self.client:
await self.client.__aexit__(None, None, None) 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: async def action_search(self) -> None:
"""Toggle search input visibility.""" """Open search modal."""
search_container = self.query_one("#search-container", Container) if self.client:
search_input = self.query_one("#search-input", Input) await self.push_screen(SearchModal(self.client))
if self.search_visible: async def on_search_modal_chunk_selected(
search_container.remove_class("visible") self, message: SearchModal.ChunkSelected
search_input.value = "" ) -> None:
self.search_visible = False """Handle chunk selection from search modal."""
self.search_active = False if not self.client:
return
# Restore full document list and reload chunks for selected document chunk = message.chunk
if self.client:
# 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) doc_list = self.query_one(DocumentList)
chunk_list = self.query_one(ChunkList) chunk_list = self.query_one(ChunkList)
# Reload all documents # Find and select the document
await doc_list.load_documents(self.client) for idx, d in enumerate(doc_list.documents):
if d.id == chunk.document_id:
# Restore document and chunk selection if doc_list.list_view:
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:
doc_list.list_view.index = idx doc_list.list_view.index = idx
doc_found = True break
break
# Reload chunks for this document (without scores) # Load chunks for this document
if doc_found: await chunk_list.load_chunks_for_document(
await chunk_list.load_chunks_for_document( self.client, chunk.document_id
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
}
) )
# Update document list to show only documents with matching chunks # Wait a tick for the ListView to process the new items
if doc_ids: self.call_after_refresh(self._select_chunk, chunk_list, chunk.id)
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()
async def on_document_list_document_selected( async def on_document_list_document_selected(
self, message: DocumentList.DocumentSelected self, message: DocumentList.DocumentSelected
@ -252,15 +158,12 @@ class InspectorApp(App): # type: ignore[misc]
if not self.client: if not self.client:
return return
# Always track current document (even during search)
self.current_document_id = message.document.id
# Show document details # Show document details
detail_view = self.query_one(DetailView) detail_view = self.query_one(DetailView)
await detail_view.show_document(message.document) await detail_view.show_document(message.document)
# Load chunks for this document (but not during search - preserve search results) # Load chunks for this document
if message.document.id and not self.search_active: if message.document.id:
chunk_list = self.query_one(ChunkList) chunk_list = self.query_one(ChunkList)
await chunk_list.load_chunks_for_document(self.client, message.document.id) await chunk_list.load_chunks_for_document(self.client, message.document.id)
@ -272,17 +175,10 @@ class InspectorApp(App): # type: ignore[misc]
Args: Args:
message: Message containing selected chunk message: Message containing selected chunk
""" """
# Always track current chunk (even during search)
self.current_chunk_id = message.chunk.id
# Show chunk details # Show chunk details
detail_view = self.query_one(DetailView) detail_view = self.query_one(DetailView)
await detail_view.show_chunk(message.chunk) 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: def run_inspector(db_path: Path | None = None) -> None:
"""Run the inspector TUI. """Run the inspector TUI.

View file

@ -1,3 +1,4 @@
from textual import on
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import VerticalScroll from textual.containers import VerticalScroll
from textual.message import Message from textual.message import Message
@ -53,43 +54,12 @@ class ChunkList(VerticalScroll):
item = ListItem(Static(f"[{chunk.order}] {first_line}")) item = ListItem(Static(f"[{chunk.order}] {first_line}"))
await self.list_view.append(item) await self.list_view.append(item)
async def load_chunks_from_search( @on(ListView.Highlighted)
self, client: HaikuRAG, query: str, limit: int = 20 @on(ListView.Selected)
async def handle_chunk_selection(
self, event: ListView.Highlighted | ListView.Selected
) -> None: ) -> None:
"""Load chunks from search results. """Handle chunk selection (arrow keys or Enter)."""
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)."""
if event.list_view == self.list_view: if event.list_view == self.list_view:
idx = event.list_view.index idx = event.list_view.index
if idx is not None and 0 <= idx < len(self.chunks): 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): class DetailView(VerticalScroll):
"""Widget for displaying detailed content of documents or chunks.""" """Widget for displaying detailed content of documents or chunks."""
can_focus = False can_focus = True
def __init__(self, **kwargs) -> None: def __init__(self, **kwargs) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)

View file

@ -1,3 +1,4 @@
from textual import on
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import VerticalScroll from textual.containers import VerticalScroll
from textual.message import Message from textual.message import Message
@ -50,16 +51,12 @@ class DocumentList(VerticalScroll):
item = ListItem(Static(f"{title}")) item = ListItem(Static(f"{title}"))
await self.list_view.append(item) await self.list_view.append(item)
async def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: @on(ListView.Highlighted)
"""Handle document navigation (arrow keys).""" @on(ListView.Selected)
if event.list_view == self.list_view and event.item is not None: async def handle_document_selection(
idx = event.list_view.index self, event: ListView.Highlighted | ListView.Selected
if idx is not None and 0 <= idx < len(self.documents): ) -> None:
document = self.documents[idx] """Handle document selection (arrow keys or Enter)."""
self.post_message(self.DocumentSelected(document))
async def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle document selection (Enter key)."""
if event.list_view == self.list_view: if event.list_view == self.list_view:
idx = event.list_view.index idx = event.list_view.index
if idx is not None and 0 <= idx < len(self.documents): 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()