Document filter in the TUI, use command palette instead of shortcuts
This commit is contained in:
parent
30e9ff3038
commit
af786596b1
2 changed files with 235 additions and 7 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
# pyright: reportPossiblyUnboundVariable=false
|
# pyright: reportPossiblyUnboundVariable=false
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterable
|
from collections.abc import AsyncIterable, Iterable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
@ -39,7 +39,7 @@ except ImportError:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import textual_image.widget # noqa: F401 - import early for renderer detection
|
import textual_image.widget # noqa: F401 - import early for renderer detection
|
||||||
from textual.app import App
|
from textual.app import App, SystemCommand
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.widgets import Footer, Header, Input
|
from textual.widgets import Footer, Header, Input
|
||||||
from textual.worker import Worker
|
from textual.worker import Worker
|
||||||
|
|
@ -50,6 +50,7 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
TEXTUAL_AVAILABLE = False
|
TEXTUAL_AVAILABLE = False
|
||||||
App = object # type: ignore
|
App = object # type: ignore
|
||||||
|
SystemCommand = object # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class ChatApp(App):
|
class ChatApp(App):
|
||||||
|
|
@ -79,10 +80,6 @@ class ChatApp(App):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
Binding("ctrl+l", "clear_chat", "Clear", show=True),
|
|
||||||
Binding("ctrl+g", "show_visual", "Visual", show=True),
|
|
||||||
Binding("ctrl+i", "show_info", "Info", show=True),
|
|
||||||
Binding("ctrl+o", "show_context", "Context", show=True),
|
|
||||||
Binding("escape", "focus_input", "Focus Input", show=False),
|
Binding("escape", "focus_input", "Focus Input", show=False),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -105,6 +102,7 @@ class ChatApp(App):
|
||||||
self._last_citations: list[Citation] = []
|
self._last_citations: list[Citation] = []
|
||||||
self._current_worker: Worker[None] | None = None
|
self._current_worker: Worker[None] | None = None
|
||||||
self._message_history: list[ModelMessage] = []
|
self._message_history: list[ModelMessage] = []
|
||||||
|
self._document_filter: list[str] = []
|
||||||
|
|
||||||
def compose(self) -> "ComposeResult":
|
def compose(self) -> "ComposeResult":
|
||||||
"""Compose the UI layout."""
|
"""Compose the UI layout."""
|
||||||
|
|
@ -113,6 +111,35 @@ class ChatApp(App):
|
||||||
yield Input(placeholder="Ask a question...", id="chat-input")
|
yield Input(placeholder="Ask a question...", id="chat-input")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
|
def get_system_commands(self, screen: Any) -> Iterable[SystemCommand]:
|
||||||
|
"""Add commands to the command palette."""
|
||||||
|
yield from super().get_system_commands(screen)
|
||||||
|
yield SystemCommand(
|
||||||
|
"Clear chat",
|
||||||
|
"Clear the chat history and reset session",
|
||||||
|
self.action_clear_chat,
|
||||||
|
)
|
||||||
|
yield SystemCommand(
|
||||||
|
"Filter documents",
|
||||||
|
"Select documents to filter searches",
|
||||||
|
self.action_show_filter,
|
||||||
|
)
|
||||||
|
yield SystemCommand(
|
||||||
|
"Show visual grounding",
|
||||||
|
"Show visual grounding for selected citation",
|
||||||
|
self.action_show_visual,
|
||||||
|
)
|
||||||
|
yield SystemCommand(
|
||||||
|
"Database info",
|
||||||
|
"Show database information",
|
||||||
|
self.action_show_info,
|
||||||
|
)
|
||||||
|
yield SystemCommand(
|
||||||
|
"Session context",
|
||||||
|
"Show current session context",
|
||||||
|
self.action_show_context,
|
||||||
|
)
|
||||||
|
|
||||||
async def on_mount(self) -> None:
|
async def on_mount(self) -> None:
|
||||||
"""Initialize the app when mounted."""
|
"""Initialize the app when mounted."""
|
||||||
self.client = HaikuRAG(
|
self.client = HaikuRAG(
|
||||||
|
|
@ -127,6 +154,7 @@ class ChatApp(App):
|
||||||
self.agent = create_chat_agent(self.config)
|
self.agent = create_chat_agent(self.config)
|
||||||
self.session_state = ChatSessionState(
|
self.session_state = ChatSessionState(
|
||||||
session_id=str(uuid.uuid4()),
|
session_id=str(uuid.uuid4()),
|
||||||
|
document_filter=self._document_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Focus the input field
|
# Focus the input field
|
||||||
|
|
@ -270,9 +298,10 @@ class ChatApp(App):
|
||||||
await chat_history.clear_messages()
|
await chat_history.clear_messages()
|
||||||
self._last_citations.clear()
|
self._last_citations.clear()
|
||||||
self._message_history.clear()
|
self._message_history.clear()
|
||||||
# Reset session state for fresh conversation
|
# Reset session state for fresh conversation (preserve document filter)
|
||||||
self.session_state = ChatSessionState(
|
self.session_state = ChatSessionState(
|
||||||
session_id=str(uuid.uuid4()),
|
session_id=str(uuid.uuid4()),
|
||||||
|
document_filter=self._document_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_focus_input(self) -> None:
|
def action_focus_input(self) -> None:
|
||||||
|
|
@ -339,3 +368,23 @@ class ChatApp(App):
|
||||||
citation_widgets = list(chat_history.query(CitationWidget))
|
citation_widgets = list(chat_history.query(CitationWidget))
|
||||||
if 0 <= event.citation_index < len(citation_widgets):
|
if 0 <= event.citation_index < len(citation_widgets):
|
||||||
citation_widgets[event.citation_index].add_class("selected")
|
citation_widgets[event.citation_index].add_class("selected")
|
||||||
|
|
||||||
|
async def action_show_filter(self) -> None:
|
||||||
|
"""Show document filter modal."""
|
||||||
|
if not self.client:
|
||||||
|
return
|
||||||
|
|
||||||
|
from haiku.rag.chat.widgets.document_filter_modal import DocumentFilterModal
|
||||||
|
|
||||||
|
await self.push_screen(
|
||||||
|
DocumentFilterModal(
|
||||||
|
client=self.client,
|
||||||
|
selected=self._document_filter,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_document_filter_modal_filter_changed(self, event: Any) -> None:
|
||||||
|
"""Handle document filter changes from modal."""
|
||||||
|
self._document_filter = event.selected
|
||||||
|
if self.session_state:
|
||||||
|
self.session_state.document_filter = self._document_filter
|
||||||
|
|
|
||||||
179
haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py
Normal file
179
haiku_rag_slim/haiku/rag/chat/widgets/document_filter_modal.py
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
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 ModalScreen
|
||||||
|
from textual.widgets import Button, Checkbox, Input, Static
|
||||||
|
|
||||||
|
from haiku.rag.client import HaikuRAG
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentFilterModal(ModalScreen): # pragma: no cover
|
||||||
|
"""Modal screen for selecting documents to filter searches."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("escape", "cancel", "Cancel", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
DocumentFilterModal {
|
||||||
|
align: center middle;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter-container {
|
||||||
|
width: 60;
|
||||||
|
height: auto;
|
||||||
|
max-height: 28;
|
||||||
|
background: $surface;
|
||||||
|
border: tall $primary;
|
||||||
|
padding: 1 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter-header {
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter-search {
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter-list {
|
||||||
|
height: 1fr;
|
||||||
|
min-height: 8;
|
||||||
|
max-height: 16;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filter-footer {
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row {
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1;
|
||||||
|
align: right middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#button-row Button {
|
||||||
|
margin-left: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-checkbox {
|
||||||
|
height: auto;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-checkbox:hover {
|
||||||
|
background: $surface-lighten-1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
class FilterChanged(Message):
|
||||||
|
"""Emitted when the document filter selection changes."""
|
||||||
|
|
||||||
|
def __init__(self, selected: list[str]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.selected = selected
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: HaikuRAG,
|
||||||
|
selected: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.client = client
|
||||||
|
self.initial_selected = selected or []
|
||||||
|
self._selected: set[str] = set(self.initial_selected)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="filter-container"):
|
||||||
|
yield Static("[bold]Filter Documents[/bold]", id="filter-header")
|
||||||
|
yield Input(placeholder="Search documents...", id="filter-search")
|
||||||
|
with VerticalScroll(id="filter-list"):
|
||||||
|
yield Static("Loading...", id="loading-indicator")
|
||||||
|
yield Static("", id="filter-footer")
|
||||||
|
with Horizontal(id="button-row"):
|
||||||
|
yield Button("Cancel", id="cancel-btn", variant="default")
|
||||||
|
yield Button("Apply", id="apply-btn", variant="primary")
|
||||||
|
|
||||||
|
async def on_mount(self) -> None:
|
||||||
|
"""Load documents when mounted."""
|
||||||
|
await self._load_documents()
|
||||||
|
|
||||||
|
async def _load_documents(self) -> None:
|
||||||
|
"""Load all documents from the client."""
|
||||||
|
docs = await self.client.list_documents()
|
||||||
|
|
||||||
|
# Remove loading indicator
|
||||||
|
loading = self.query_one("#loading-indicator", Static)
|
||||||
|
loading.remove()
|
||||||
|
|
||||||
|
# Add checkboxes for all documents
|
||||||
|
filter_list = self.query_one("#filter-list", VerticalScroll)
|
||||||
|
for doc in docs:
|
||||||
|
display_name = doc.title or doc.uri or str(doc.id)
|
||||||
|
checkbox = Checkbox(
|
||||||
|
display_name,
|
||||||
|
value=display_name in self._selected,
|
||||||
|
id=f"doc-{hash(display_name)}",
|
||||||
|
classes="doc-checkbox",
|
||||||
|
)
|
||||||
|
checkbox._doc_id = display_name # type: ignore[attr-defined]
|
||||||
|
await filter_list.mount(checkbox)
|
||||||
|
|
||||||
|
self._update_footer()
|
||||||
|
|
||||||
|
def _update_footer(self) -> None:
|
||||||
|
"""Update the footer with selection count."""
|
||||||
|
footer = self.query_one("#filter-footer", Static)
|
||||||
|
count = len(self._selected)
|
||||||
|
if count == 0:
|
||||||
|
footer.update("[dim]No filter (all documents)[/dim]")
|
||||||
|
else:
|
||||||
|
footer.update(f"[bold]{count}[/bold] document(s) selected")
|
||||||
|
|
||||||
|
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
||||||
|
"""Handle checkbox state changes."""
|
||||||
|
checkbox = event.checkbox
|
||||||
|
doc_id = getattr(checkbox, "_doc_id", None)
|
||||||
|
if doc_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.value:
|
||||||
|
self._selected.add(doc_id)
|
||||||
|
else:
|
||||||
|
self._selected.discard(doc_id)
|
||||||
|
|
||||||
|
self._update_footer()
|
||||||
|
|
||||||
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
|
"""Filter document list based on search input."""
|
||||||
|
search_term = event.value.lower().strip()
|
||||||
|
filter_list = self.query_one("#filter-list", VerticalScroll)
|
||||||
|
|
||||||
|
for checkbox in filter_list.query(Checkbox):
|
||||||
|
doc_id = getattr(checkbox, "_doc_id", "")
|
||||||
|
if search_term == "" or search_term in doc_id.lower():
|
||||||
|
checkbox.display = True
|
||||||
|
else:
|
||||||
|
checkbox.display = False
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
"""Handle button presses."""
|
||||||
|
if event.button.id == "cancel-btn":
|
||||||
|
self.action_cancel()
|
||||||
|
elif event.button.id == "apply-btn":
|
||||||
|
self.action_confirm()
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
"""Cancel and close without saving."""
|
||||||
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
def action_confirm(self) -> None:
|
||||||
|
"""Confirm selection and close."""
|
||||||
|
self.post_message(self.FilterChanged(list(self._selected)))
|
||||||
|
self.app.pop_screen()
|
||||||
Loading…
Reference in a new issue