diff --git a/CHANGELOG.md b/CHANGELOG.md
index c5ab3b8d..756b971d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
### Added
- **Document virtual filesystem in analysis sandbox**: Documents mounted at `/documents/{id}/` with `metadata.json` (eager), `content.txt` (lazy), and `items.jsonl` (lazy). Standard Python `pathlib.Path` for browsing and reading document content and structure.
-- **`execute_code` skill tool**: Direct code execution in the sandbox, surfaced as individual AG-UI events in the chat TUI
+- **`execute_code` skill tool**: Direct code execution in the sandbox, surfaced as individual AG-UI events in the chat TUI. Items VFS uses a lazy bulk cache (~1s for 1000 documents vs 60s+ per-document queries).
- **`cite` skill tool**: Explicit citation registration with per-turn tracking via `citation_index` and `citations` fields in state
- **`--skill` flag for chat TUI**: `haiku-rag chat -s rag -s analysis` to enable specific skills
- **`--model` overrides all agents**: Chat, QA, research, and analysis agents all use the specified model
@@ -24,6 +24,8 @@
- **`list_documents` skill tool** takes no parameters — returns all documents
- **Per-turn citation tracking**: `citation_index: dict[str, Citation]` (deduplicated) + `citations: list[list[str]]` (per-turn chunk IDs) replaces flat citation list
- **Search rate limiting**: Skill search tool enforces `config.qa.max_searches`
+- **Context expansion respects section boundaries**: Sections within the char budget are returned whole regardless of item count. Too-large sections expand bounded by section edges. Adjacent sections no longer merge — only overlapping ranges do.
+- **Visualization shows full expanded section**: `visualize_chunk` expands context before resolving bounding boxes, so all pages the section spans get highlighted.
### Removed
@@ -35,6 +37,8 @@
- **`create_analysis_toolset()`**: Removed unused `tools/analysis.py` module
- **`qa_history`, `reports` from skill state**: Conversational context handled by the outer chat agent
- **`combine_filters`, `build_document_filter`**: Removed from public API
+- **`max_context_items`**: Removed from `SearchConfig` — `max_context_chars` is the sole expansion constraint
+- **`QAHistoryEntry`, `tools/qa.py`**: Removed unused QA history model and relevance threshold
## [0.40.1] - 2026-04-17
diff --git a/app/frontend/components/Chat.tsx b/app/frontend/components/Chat.tsx
index 9fbb3028..17a50eca 100644
--- a/app/frontend/components/Chat.tsx
+++ b/app/frontend/components/Chat.tsx
@@ -204,6 +204,10 @@ function ToolCallIndicator({
);
}
+ case "cite":
+ return Registering citations;
+ case "list_documents":
+ return Listing documents;
default:
return Processing...;
}
diff --git a/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py b/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py
deleted file mode 100644
index 3dc74659..00000000
--- a/haiku_rag_slim/haiku/rag/chat/widgets/context_modal.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from textual.app import ComposeResult
-from textual.binding import Binding
-from textual.containers import Horizontal, Vertical, VerticalScroll
-from textual.screen import ModalScreen
-from textual.widgets import Button, Markdown, Static
-
-
-class ContextModal(ModalScreen):
- """Modal screen for viewing session Q&A history."""
-
- BINDINGS = [
- Binding("escape", "cancel", "Close", show=False),
- Binding("ctrl+o", "cancel", "Close", show=False),
- ]
-
- CSS = """
- ContextModal {
- align: center middle;
- background: rgba(0, 0, 0, 0.5);
- }
-
- #context-container {
- width: 70;
- height: auto;
- max-height: 32;
- background: $surface;
- border: tall $primary;
- padding: 1 2;
- }
-
- #context-header {
- height: auto;
- margin-bottom: 1;
- }
-
- #context-description {
- height: auto;
- margin-bottom: 1;
- color: $text-muted;
- }
-
- #context-content {
- height: 1fr;
- max-height: 16;
- scrollbar-gutter: stable;
- }
-
- #button-row {
- height: auto;
- margin-top: 1;
- align: right middle;
- }
-
- #button-row Button {
- margin-left: 1;
- min-width: 10;
- }
- """
-
- def __init__(self, qa_history: list | None = None) -> None:
- super().__init__()
- self._qa_history = qa_history or []
-
- def compose(self) -> ComposeResult:
- with Vertical(id="context-container"):
- yield Static("[bold]Session Context[/bold]", id="context-header")
- yield Static(
- "Questions and answers from this session.",
- id="context-description",
- )
- with VerticalScroll(id="context-content"):
- yield Markdown(self._get_content())
- with Horizontal(id="button-row"):
- yield Button("Close", id="cancel-btn", variant="primary")
-
- def _get_content(self) -> str:
- if not self._qa_history:
- return "*No questions asked yet.*"
-
- parts = []
- for entry in self._qa_history:
- q = getattr(entry, "question", str(entry))
- a = getattr(entry, "answer", "")
- parts.append(f"**Q:** {q}\n\n**A:** {a}")
-
- return "\n\n---\n\n".join(parts)
-
- def on_button_pressed(self, event: Button.Pressed) -> None:
- """Handle button presses."""
- if event.button.id == "cancel-btn":
- self.action_cancel()
-
- def action_cancel(self) -> None:
- """Cancel and close."""
- self.app.pop_screen()
diff --git a/haiku_rag_slim/haiku/rag/tools/__init__.py b/haiku_rag_slim/haiku/rag/tools/__init__.py
index 384ead52..156a003e 100644
--- a/haiku_rag_slim/haiku/rag/tools/__init__.py
+++ b/haiku_rag_slim/haiku/rag/tools/__init__.py
@@ -1,12 +1,9 @@
from haiku.rag.tools.context import RAGDeps
from haiku.rag.tools.document import create_document_toolset
from haiku.rag.tools.filters import build_multi_document_filter
-from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD, QAHistoryEntry
from haiku.rag.tools.search import create_search_toolset
__all__ = [
- "PRIOR_ANSWER_RELEVANCE_THRESHOLD",
- "QAHistoryEntry",
"RAGDeps",
"build_multi_document_filter",
"create_document_toolset",
diff --git a/haiku_rag_slim/haiku/rag/tools/qa.py b/haiku_rag_slim/haiku/rag/tools/qa.py
deleted file mode 100644
index 116377ea..00000000
--- a/haiku_rag_slim/haiku/rag/tools/qa.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from pydantic import BaseModel, Field
-
-from haiku.rag.agents.research.models import Citation, SearchAnswer
-
-PRIOR_ANSWER_RELEVANCE_THRESHOLD = 0.7
-
-
-class QAHistoryEntry(BaseModel):
- """A Q&A pair with optional cached embedding for similarity matching."""
-
- question: str
- answer: str
- confidence: float = 0.9
- citations: list[Citation] = Field(default_factory=list)
- question_embedding: list[float] | None = Field(default=None, exclude=True)
-
- @property
- def sources(self) -> list[str]:
- """Source names for display."""
- return list(
- dict.fromkeys(c.document_title or c.document_uri for c in self.citations)
- )
-
- def to_search_answer(self) -> SearchAnswer:
- """Convert to SearchAnswer for research graph context."""
- return SearchAnswer(
- query=self.question,
- answer=self.answer,
- confidence=self.confidence,
- cited_chunks=[c.chunk_id for c in self.citations],
- citations=self.citations,
- )
diff --git a/tests/tools/test_qa.py b/tests/tools/test_qa.py
deleted file mode 100644
index f21aee3a..00000000
--- a/tests/tools/test_qa.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from haiku.rag.agents.research.models import Citation
-from haiku.rag.tools.qa import PRIOR_ANSWER_RELEVANCE_THRESHOLD, QAHistoryEntry
-
-
-class TestQAHistoryEntry:
- """Tests for QAHistoryEntry model."""
-
- def test_defaults(self):
- """QAHistoryEntry has sensible defaults."""
- entry = QAHistoryEntry(question="What is X?", answer="X is Y.")
- assert entry.confidence == 0.9
- assert entry.citations == []
- assert entry.question_embedding is None
-
- def test_sources_property(self):
- """sources returns unique document titles."""
- citations = [
- Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="doc1.md",
- document_title="Document One",
- content="Content 1",
- ),
- Citation(
- document_id="d1",
- chunk_id="c2",
- document_uri="doc1.md",
- document_title="Document One",
- content="Content 2",
- ),
- Citation(
- document_id="d2",
- chunk_id="c3",
- document_uri="doc2.md",
- document_title="Document Two",
- content="Content 3",
- ),
- ]
- entry = QAHistoryEntry(question="Q", answer="A", citations=citations)
- sources = entry.sources
- assert len(sources) == 2
- assert "Document One" in sources
- assert "Document Two" in sources
-
- def test_sources_uses_uri_as_fallback(self):
- """sources uses uri when title is None."""
- citations = [
- Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="test.md",
- document_title=None,
- content="Content",
- ),
- ]
- entry = QAHistoryEntry(question="Q", answer="A", citations=citations)
- assert entry.sources == ["test.md"]
-
- def test_to_search_answer(self):
- """to_search_answer converts to SearchAnswer."""
- citation = Citation(
- document_id="d1",
- chunk_id="c1",
- document_uri="doc1.md",
- document_title="Doc",
- content="Content",
- )
- entry = QAHistoryEntry(
- question="What is X?",
- answer="X is Y.",
- confidence=0.85,
- citations=[citation],
- )
- sa = entry.to_search_answer()
- assert sa.query == "What is X?"
- assert sa.answer == "X is Y."
- assert sa.confidence == 0.85
- assert sa.cited_chunks == ["c1"]
- assert len(sa.citations) == 1
-
- def test_question_embedding_excluded_from_serialization(self):
- """question_embedding is excluded from model_dump."""
- entry = QAHistoryEntry(
- question="Q",
- answer="A",
- question_embedding=[0.1, 0.2],
- )
- data = entry.model_dump()
- assert "question_embedding" not in data
-
-
-def test_prior_answer_relevance_threshold():
- """PRIOR_ANSWER_RELEVANCE_THRESHOLD is a sensible value."""
- assert 0 < PRIOR_ANSWER_RELEVANCE_THRESHOLD < 1