Render database names and failures verbatim in the inspector

A database name, a location and an exception message all reach the info modal
as configuration-derived text, and Rich reads `[...]` in any of them as markup:
a name like `beta [prod]` disappeared, and a message carrying `[Errno 2]` or a
stray closing tag could break the line it sat in.
This commit is contained in:
Yiorgis Gozadinos 2026-08-28 14:02:29 +03:00
parent 7dc853606f
commit 1de1b662ae
No known key found for this signature in database
2 changed files with 54 additions and 12 deletions

View file

@ -1,6 +1,7 @@
import asyncio import asyncio
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from rich.markup import escape
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Vertical, VerticalScroll from textual.containers import Vertical, VerticalScroll
@ -35,12 +36,11 @@ async def database_lines(client: "HaikuRAG") -> list[str]:
db = client.store.db db = client.store.db
stats = await get_database_stats(db) stats = await get_database_stats(db)
except Exception as e: except Exception as e:
return [f"[red]Failed to open database: {e}[/red]"] return [f"[red]Failed to open database: {escape(str(e))}[/red]"]
# The store read these on open; a second query would re-read and re-parse # The store read and parsed these on open.
# the same blob.
settings = client.store.stored_settings settings = client.store.stored_settings
stored_version = str(settings.get("version", "unknown")) stored_version = escape(str(settings.get("version", "unknown")))
embed_model_obj = settings.get("embeddings", {}).get("model", {}) embed_model_obj = settings.get("embeddings", {}).get("model", {})
embed_provider = embed_model_obj.get("provider") embed_provider = embed_model_obj.get("provider")
embed_model = embed_model_obj.get("name") embed_model = embed_model_obj.get("name")
@ -56,9 +56,9 @@ async def database_lines(client: "HaikuRAG") -> list[str]:
) )
if embed_provider or embed_model or vector_dim: if embed_provider or embed_model or vector_dim:
provider_part = embed_provider or "unknown" provider_part = escape(str(embed_provider or "unknown"))
model_part = embed_model or "unknown" model_part = escape(str(embed_model or "unknown"))
dim_part = f"{vector_dim}" if vector_dim is not None else "unknown" dim_part = escape(str(vector_dim)) if vector_dim is not None else "unknown"
lines.append( lines.append(
f"[bold $accent]embeddings[/bold $accent]: " f"[bold $accent]embeddings[/bold $accent]: "
f"{provider_part}/{model_part} (dim: {dim_part})" f"{provider_part}/{model_part} (dim: {dim_part})"
@ -172,11 +172,15 @@ class InfoModal(ModalScreen):
for block in blocks: for block in blocks:
lines.extend(block) lines.extend(block)
else: else:
lines.append(f"[bold $accent]location[/bold $accent]: {location}") lines.append(
f"[bold $accent]location[/bold $accent]: {escape(str(location))}"
)
lines.extend(await database_lines(self.client)) lines.extend(await database_lines(self.client))
lines.append("[bold]Versions[/bold]") lines.append("[bold]Versions[/bold]")
versions = get_package_versions() versions = {
key: escape(str(value)) for key, value in get_package_versions().items()
}
lines.append(f"[bold $accent]haiku.rag[/bold $accent]: {versions['haiku_rag']}") lines.append(f"[bold $accent]haiku.rag[/bold $accent]: {versions['haiku_rag']}")
lines.append(f"[bold $accent]lancedb[/bold $accent]: {versions['lancedb']}") lines.append(f"[bold $accent]lancedb[/bold $accent]: {versions['lancedb']}")
lines.append(f"[bold $accent]docling[/bold $accent]: {versions['docling']}") lines.append(f"[bold $accent]docling[/bold $accent]: {versions['docling']}")
@ -192,13 +196,13 @@ class InfoModal(ModalScreen):
async def _report(self, name: str) -> list[str]: async def _report(self, name: str) -> list[str]:
"""One database's block, including its own failure to open.""" """One database's block, including its own failure to open."""
lines = [f"[bold]{name}[/bold]"] lines = [f"[bold]{escape(name)}[/bold]"]
try: try:
(owner,) = await self.client.clients_for([name]) (owner,) = await self.client.clients_for([name])
except Exception as e: except Exception as e:
# The client names a configured database by name and never by # The client names a configured database by name and never by
# location, so its message is safe to show. # location, so its message is safe to show.
return [*lines, f"[red]{e}[/red]", ""] return [*lines, f"[red]{escape(str(e))}[/red]", ""]
return [*lines, *await database_lines(owner)] return [*lines, *await database_lines(owner)]
async def action_dismiss(self, result=None) -> None: async def action_dismiss(self, result=None) -> None:

View file

@ -446,9 +446,47 @@ class TestReportingEachDatabase:
"database 'beta' could not be opened: OSError" "database 'beta' could not be opened: OSError"
) )
modal.client = client modal.client = client
modal.db_path = None
lines = await modal._report("beta") lines = await modal._report("beta")
assert lines[0] == "[bold]beta[/bold]" assert lines[0] == "[bold]beta[/bold]"
assert "could not be opened" in lines[1] assert "could not be opened" in lines[1]
@pytest.mark.asyncio
async def test_a_failure_with_bracketed_text_renders_literally(self):
"""Error text and database names render verbatim, not as Rich markup."""
from rich.text import Text
from haiku.rag.inspector.widgets.info_modal import InfoModal
from haiku.rag.store.exceptions import SourceUnavailableError
modal = InfoModal.__new__(InfoModal)
client = AsyncMock()
message = "database 'beta [prod]' could not be opened: [/red] [Errno 2]"
client.clients_for.side_effect = SourceUnavailableError(message)
modal.client = client
lines = await modal._report("beta [prod]")
assert Text.from_markup(lines[0]).plain == "beta [prod]"
assert message in Text.from_markup(lines[1]).plain
@pytest.mark.asyncio
async def test_an_open_failure_with_bracketed_text_renders_literally(
self, tmp_path
):
"""A stats-read failure renders its message verbatim, not as markup."""
from rich.text import Text
from haiku.rag.inspector.widgets.info_modal import database_lines
from haiku.rag.store.engine import ConnectionMode
client = MagicMock()
client.store.db_path = tmp_path
client.store._connection_mode = ConnectionMode.LOCAL
message = "Schema error: no field named [vector, text] [/red]"
type(client.store).db = property(MagicMock(side_effect=RuntimeError(message)))
lines = await database_lines(client)
assert message in Text.from_markup(lines[0]).plain