From acfb4e07f72b32cb203933e9fc8fe7ea363ece71 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 08:29:53 -0500 Subject: [PATCH 01/71] agentgrep(feat[export]): Add safe core export why: Export needs one frontend-neutral artifact contract and a file sink that cannot overwrite source stores or leak source metadata. what: - Render allowlisted deterministic NDJSON and Markdown artifacts. - Preserve observed thread fidelity while preparing identities once. - Write private artifacts with atomic no-clobber and alias guards. --- src/agentgrep/conversations.py | 44 +- src/agentgrep/record_export.py | 638 +++++++++++++++++++++++ tests/test_conversations.py | 60 +++ tests/test_record_export.py | 896 +++++++++++++++++++++++++++++++++ 4 files changed, 1631 insertions(+), 7 deletions(-) create mode 100644 src/agentgrep/record_export.py create mode 100644 tests/test_record_export.py diff --git a/src/agentgrep/conversations.py b/src/agentgrep/conversations.py index 5c9ee61fc..e2071dcf1 100644 --- a/src/agentgrep/conversations.py +++ b/src/agentgrep/conversations.py @@ -15,6 +15,7 @@ "ConversationFidelity", "ConversationUnit", "group_conversation_units", + "group_prepared_conversation_units", ) type ConversationFidelity = t.Literal["native_tree", "source_order", "unordered"] @@ -407,15 +408,15 @@ def _build_conversation_unit( ) -def group_conversation_units( - records: cabc.Iterable[SearchRecord], +def group_prepared_conversation_units( + records: cabc.Iterable[tuple[SearchRecord, RecordIdentity]], ) -> tuple[ConversationUnit, ...]: - """Group an observed record subset by canonical thread identity. + """Group records whose canonical identities are already prepared. Parameters ---------- records - Normalized records to consume exactly once. + ``(record, identity)`` pairs to consume exactly once. Returns ------- @@ -428,14 +429,43 @@ def group_conversation_units( completeness, choose a revision or branch, or invent timestamp order. """ groups: dict[str, list[_PreparedRecord]] = {} - for record in records: - thread_id = record_thread_id(record) + for record, identity in records: + thread_id = identity.thread_id if thread_id is None: continue - identity = record_identity(record, prepared_thread_id=thread_id) prepared = _prepare_record(record, identity) groups.setdefault(thread_id, []).append(prepared) return tuple( _build_conversation_unit(thread_id, groups[thread_id]) for thread_id in sorted(groups) ) + + +def group_conversation_units( + records: cabc.Iterable[SearchRecord], +) -> tuple[ConversationUnit, ...]: + """Group an observed record subset by canonical thread identity. + + Parameters + ---------- + records + Normalized records to consume exactly once. + + Returns + ------- + tuple[ConversationUnit, ...] + Canonically ordered units for records with defensible thread IDs. + + Notes + ----- + Threadless records are rejected before cryptographic identity preparation. + """ + + def prepare() -> cabc.Iterator[tuple[SearchRecord, RecordIdentity]]: + for record in records: + thread_id = record_thread_id(record) + if thread_id is None: + continue + yield record, record_identity(record, prepared_thread_id=thread_id) + + return group_prepared_conversation_units(prepare()) diff --git a/src/agentgrep/record_export.py b/src/agentgrep/record_export.py new file mode 100644 index 000000000..0ef3fd127 --- /dev/null +++ b/src/agentgrep/record_export.py @@ -0,0 +1,638 @@ +"""Deterministic rendering and private file output for normalized records.""" + +from __future__ import annotations + +import collections.abc as cabc +import contextlib +import dataclasses +import json +import os +import pathlib +import re +import secrets +import stat +import typing as t + +from agentgrep.conversations import ConversationFidelity, group_prepared_conversation_units +from agentgrep.identity import RecordIdentity, record_identity +from agentgrep.records import SCHEMA_VERSION, SearchRecord + +__all__ = ( + "ExportArtifact", + "ExportEncodingError", + "ExportError", + "ExportExistsError", + "ExportFormat", + "ExportFormatError", + "ExportSafetyError", + "ExportSelection", + "ExportSelectionError", + "ExportWriteError", + "render_export", + "write_export", + "write_private_export", +) + +type ExportFormat = t.Literal["ndjson", "markdown"] +type ExportSelection = t.Literal["records", "thread"] +type _ProtectedPaths = cabc.Iterable[str | os.PathLike[str]] + + +class ExportError(Exception): + """Base class for path-free export failures.""" + + +class ExportFormatError(ExportError): + """The requested export format is unsupported.""" + + +class ExportSelectionError(ExportError): + """The selected records cannot form the requested export unit.""" + + +class ExportEncodingError(ExportError): + """The selected values cannot be represented by the export format.""" + + +class ExportExistsError(ExportError): + """The destination already exists and overwrite was not requested.""" + + +class ExportSafetyError(ExportError): + """The destination violates an export path-safety invariant.""" + + +class ExportWriteError(ExportError): + """The artifact could not be durably written.""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class ExportArtifact: + """One frontend-neutral rendered export.""" + + format: ExportFormat + selection: ExportSelection + record_count: int + thread_id: str | None + fidelity: ConversationFidelity | None + text: str + byte_count: int + + +class _ExportRecordPayload(t.TypedDict): + """Allowlisted portable fields for one normalized record.""" + + schema_version: str + agent: str + store: str + kind: str + role: str | None + timestamp: str | None + model: str | None + content_id: str + record_id: str | None + record_id_stability: str | None + thread_id: str | None + text: t.NotRequired[str] + + +@dataclasses.dataclass(frozen=True, slots=True) +class _PreparedRecord: + """One record paired with its cached identity and portable payload.""" + + record: SearchRecord + identity: RecordIdentity + payload: _ExportRecordPayload + + +def _prepare_records( + records: cabc.Iterable[SearchRecord], + *, + include_bodies: bool, +) -> tuple[_PreparedRecord, ...]: + """Prepare each selected record identity and payload once.""" + prepared: list[_PreparedRecord] = [] + for record in records: + identity = record_identity(record) + payload: _ExportRecordPayload = { + "schema_version": SCHEMA_VERSION, + "agent": record.agent, + "store": record.store, + "kind": record.kind, + "role": record.role, + "timestamp": record.timestamp, + "model": record.model, + "content_id": identity.content_id, + "record_id": identity.record_id, + "record_id_stability": identity.record_id_stability, + "thread_id": identity.thread_id, + } + if include_bodies: + payload["text"] = record.text + prepared.append(_PreparedRecord(record, identity, payload)) + return tuple(prepared) + + +def _canonical_json(payload: _ExportRecordPayload) -> str: + """Return stable ASCII JSON, including lone-surrogate escapes.""" + return json.dumps(payload, ensure_ascii=True, separators=(",", ":"), sort_keys=True) + + +def _record_sort_key(prepared: _PreparedRecord) -> tuple[object, ...]: + """Return the export-owned total ordering key.""" + identity = prepared.identity + timestamp = prepared.record.timestamp + return ( + identity.thread_id is None, + identity.thread_id or "", + timestamp is None, + timestamp or "", + identity.record_id is None, + identity.record_id or identity.content_id, + identity.content_id, + _canonical_json(prepared.payload), + ) + + +def _render_ndjson(records: tuple[_PreparedRecord, ...]) -> str: + """Render one canonical object per line.""" + return "".join(f"{_canonical_json(record.payload)}\n" for record in records) + + +def _require_utf8(value: str) -> None: + """Reject a non-UTF-8 Unicode scalar with a path-free error.""" + try: + value.encode("utf-8") + except UnicodeEncodeError: + message = "markdown export contains text that is not valid UTF-8" + raise ExportEncodingError(message) from None + + +def _markdown_scalar(value: str | None) -> str: + """Render one nullable scalar without admitting Markdown structure.""" + if value is None: + return "null" + _require_utf8(value) + encoded = json.dumps(value, ensure_ascii=False)[1:-1] + return re.sub(r"([\\`*_{}\[\]<>#|])", r"\\\1", encoded) + + +def _body_fence(body: str) -> str: + """Return a backtick fence longer than every run in ``body``.""" + longest = max((len(match.group()) for match in re.finditer(r"`+", body)), default=0) + return "`" * max(3, longest + 1) + + +def _render_markdown( + records: tuple[_PreparedRecord, ...], + *, + selection: ExportSelection, + thread_id: str | None, + fidelity: ConversationFidelity | None, +) -> str: + """Render allowlisted human-readable Markdown.""" + noun = "observed thread" if selection == "thread" else "record" + lines = [f"# agentgrep {noun} export", "", f"- Selection: {selection}"] + lines.append(f"- Record count: {len(records)}") + if thread_id is not None: + lines.append(f"- Thread ID: {_markdown_scalar(thread_id)}") + if fidelity is not None: + lines.append(f"- Fidelity: {fidelity}") + + for index, prepared in enumerate(records, start=1): + payload = prepared.payload + lines.extend( + ( + "", + f"## Record {index}", + "", + f"- Agent: {_markdown_scalar(payload['agent'])}", + f"- Store: {_markdown_scalar(payload['store'])}", + f"- Kind: {_markdown_scalar(payload['kind'])}", + f"- Role: {_markdown_scalar(payload['role'])}", + f"- Timestamp: {_markdown_scalar(payload['timestamp'])}", + f"- Model: {_markdown_scalar(payload['model'])}", + f"- Content ID: {_markdown_scalar(payload['content_id'])}", + f"- Record ID: {_markdown_scalar(payload['record_id'])}", + f"- Record ID stability: {_markdown_scalar(payload['record_id_stability'])}", + f"- Thread ID: {_markdown_scalar(payload['thread_id'])}", + ), + ) + if "text" in payload: + body = payload["text"] + _require_utf8(body) + fence = _body_fence(body) + lines.extend(("", "### Body", "", f"{fence}text", body, fence)) + text = "\n".join(lines) + "\n" + _require_utf8(text) + return text + + +def render_export( + records: cabc.Iterable[SearchRecord], + *, + format: ExportFormat, # noqa: A002 - required public keyword. + include_bodies: bool, + selection: ExportSelection = "records", +) -> ExportArtifact: + """Render records into one deterministic portable artifact. + + Parameters + ---------- + records + Normalized records to consume once. + format + ``ndjson`` or ``markdown``. + include_bodies + Whether to include exact record text. + selection + Flat records or one observed canonical thread. + + Returns + ------- + ExportArtifact + Immutable rendered text and byte metadata. + """ + if format not in {"ndjson", "markdown"}: + message = "unsupported export format" + raise ExportFormatError(message) + if selection not in {"records", "thread"}: + message = "unsupported export selection" + raise ExportSelectionError(message) + + selected = tuple(records) + prepared = _prepare_records(selected, include_bodies=include_bodies) + thread_id: str | None = None + fidelity: ConversationFidelity | None = None + if selection == "thread": + units = group_prepared_conversation_units((item.record, item.identity) for item in prepared) + if len(units) != 1 or len(units[0].records) != len(selected): + message = "thread export requires exactly one observed thread" + raise ExportSelectionError(message) + thread_id = units[0].thread_id + fidelity = units[0].fidelity + + prepared = tuple(sorted(prepared, key=_record_sort_key)) + text = ( + _render_ndjson(prepared) + if format == "ndjson" + else _render_markdown( + prepared, + selection=selection, + thread_id=thread_id, + fidelity=fidelity, + ) + ) + return ExportArtifact( + format, + selection, + len(prepared), + thread_id, + fidelity, + text, + len(text.encode("utf-8")), + ) + + +def _artifact_bytes(artifact: ExportArtifact) -> bytes: + """Return validated bytes for a rendered artifact.""" + try: + payload = artifact.text.encode("utf-8") + except UnicodeEncodeError: + message = "export artifact is not valid UTF-8" + raise ExportWriteError(message) from None + if len(payload) != artifact.byte_count: + message = "export artifact byte count is inconsistent" + raise ExportWriteError(message) + return payload + + +def _absolute(path: pathlib.Path) -> pathlib.Path: + """Return a normalized absolute path without resolving symlinks.""" + # ``Path.resolve()`` would follow a path before the safety walk. + return pathlib.Path(os.path.abspath(os.fspath(path))) # noqa: PTH100 + + +def _directory_flags() -> int: + """Return directory flags that reject a final symlink.""" + no_follow = getattr(os, "O_NOFOLLOW", 0) + directory = getattr(os, "O_DIRECTORY", 0) + if not no_follow or not directory: + message = "export destination is unsafe on this platform" + raise ExportSafetyError(message) + return os.O_RDONLY | no_follow | directory | getattr(os, "O_CLOEXEC", 0) + + +def _close_quietly(fd: int) -> None: + """Close a cleanup descriptor without masking the primary result.""" + with contextlib.suppress(OSError): + os.close(fd) + + +def _unlink_quietly(directory_fd: int, name: str) -> None: + """Remove temporary cleanup debris when it still exists.""" + with contextlib.suppress(OSError): + os.unlink(name, dir_fd=directory_fd) + + +def _require_directory(component_stat: os.stat_result) -> None: + """Reject a symlink or non-directory path component.""" + if stat.S_ISLNK(component_stat.st_mode) or not stat.S_ISDIR(component_stat.st_mode): + message = "export destination is unsafe" + raise ExportSafetyError(message) + + +def _open_directory(path: pathlib.Path, *, create_private: bool) -> int: + """Open or create a directory tree without traversing symlinks.""" + absolute = _absolute(path) + if create_private and absolute == pathlib.Path(os.sep): + message = "private export directory is unsafe" + raise ExportSafetyError(message) + flags = _directory_flags() + try: + current_fd = os.open(os.sep, flags) + except OSError: + message = "export destination could not be written" + raise ExportWriteError(message) from None + try: + for component in absolute.parts[1:]: + try: + component_stat = os.stat( + component, + dir_fd=current_fd, + follow_symlinks=False, + ) + except FileNotFoundError: + if not create_private: + message = "export destination could not be written" + raise ExportWriteError(message) from None + try: + os.mkdir(component, 0o700, dir_fd=current_fd) + component_stat = os.stat( + component, + dir_fd=current_fd, + follow_symlinks=False, + ) + except OSError: + message = "private export directory could not be created" + raise ExportWriteError(message) from None + except OSError: + message = "export destination is unsafe" + raise ExportSafetyError(message) from None + _require_directory(component_stat) + try: + next_fd = os.open(component, flags, dir_fd=current_fd) + except OSError: + message = "export destination is unsafe" + raise ExportSafetyError(message) from None + _close_quietly(current_fd) + current_fd = next_fd + if create_private: + try: + os.fchmod(current_fd, 0o700) + except OSError: + message = "private export directory could not be created" + raise ExportWriteError(message) from None + except BaseException: + _close_quietly(current_fd) + raise + return current_fd + + +def _destination_stat(directory_fd: int, name: str) -> os.stat_result | None: + """Inspect a final component without following it.""" + try: + result = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) + except FileNotFoundError: + return None + except OSError: + message = "export destination could not be inspected" + raise ExportWriteError(message) from None + if stat.S_ISLNK(result.st_mode) or not stat.S_ISREG(result.st_mode): + message = "export destination is unsafe" + raise ExportSafetyError(message) + return result + + +def _reject_protected_alias( + destination: pathlib.Path, + destination_stat: os.stat_result | None, + protected_paths: _ProtectedPaths, +) -> None: + """Reject lexical, resolved, and inode aliases of source paths.""" + lexical = os.path.normcase(os.fspath(_absolute(destination))) + resolved = os.path.normcase(os.path.realpath(lexical)) + for value in protected_paths: + protected = pathlib.Path(value) + protected_lexical = os.path.normcase(os.fspath(_absolute(protected))) + if lexical == protected_lexical or resolved == os.path.normcase( + os.path.realpath(protected_lexical), + ): + message = "export destination aliases a protected source" + raise ExportSafetyError(message) + if destination_stat is None: + continue + try: + protected_stat = protected.stat() + except FileNotFoundError: + continue + except OSError: + message = "export destination aliases a protected source" + raise ExportSafetyError(message) from None + if (destination_stat.st_dev, destination_stat.st_ino) == ( + protected_stat.st_dev, + protected_stat.st_ino, + ): + message = "export destination aliases a protected source" + raise ExportSafetyError(message) + + +def _new_temporary(directory_fd: int) -> tuple[str, int]: + """Create a private same-directory temporary file.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + for _attempt in range(128): + name = f".agentgrep-export-{secrets.token_hex(12)}.tmp" + try: + file_fd = os.open(name, flags, 0o600, dir_fd=directory_fd) + except FileExistsError: + continue + except OSError: + message = "export destination could not be written" + raise ExportWriteError(message) from None + try: + os.fchmod(file_fd, 0o600) + except OSError: + _close_quietly(file_fd) + _unlink_quietly(directory_fd, name) + message = "export destination could not be written" + raise ExportWriteError(message) from None + return name, file_fd + message = "export destination could not be written" + raise ExportWriteError(message) + + +def _write_all(file_fd: int, payload: bytes) -> None: + """Write every byte, retrying positive short writes.""" + view = memoryview(payload) + offset = 0 + while offset < len(view): + written = os.write(file_fd, view[offset:]) + if written <= 0: + raise OSError + offset += written + + +def write_export( + artifact: ExportArtifact, + destination: str | os.PathLike[str], + *, + force: bool = False, + protected_paths: _ProtectedPaths = (), +) -> pathlib.Path: + """Atomically write an artifact without following destination links. + + Parameters + ---------- + artifact + Fully rendered portable artifact. + destination + Explicit destination file. + force + Whether to replace an existing regular file. + protected_paths + Source paths that the destination must not alias. + + Returns + ------- + pathlib.Path + The caller-supplied destination value. + """ + payload = _artifact_bytes(artifact) + result = pathlib.Path(destination) + absolute = _absolute(result) + if not absolute.name: + message = "export destination is unsafe" + raise ExportSafetyError(message) + + protected = tuple(protected_paths) + _reject_protected_alias(absolute, None, protected) + directory_fd = _open_directory(absolute.parent, create_private=False) + temporary: str | None = None + try: + existing = _destination_stat(directory_fd, absolute.name) + _reject_protected_alias(absolute, existing, protected) + if existing is not None and not force: + message = "export destination already exists" + raise ExportExistsError(message) + + temporary, file_fd = _new_temporary(directory_fd) + try: + _write_all(file_fd, payload) + os.fsync(file_fd) + finally: + _close_quietly(file_fd) + + if force: + current = _destination_stat(directory_fd, absolute.name) + _reject_protected_alias(absolute, current, protected) + os.replace( + temporary, + absolute.name, + src_dir_fd=directory_fd, + dst_dir_fd=directory_fd, + ) + else: + try: + os.link( + temporary, + absolute.name, + src_dir_fd=directory_fd, + dst_dir_fd=directory_fd, + follow_symlinks=False, + ) + except FileExistsError: + message = "export destination already exists" + raise ExportExistsError(message) from None + os.unlink(temporary, dir_fd=directory_fd) + temporary = None + os.fsync(directory_fd) + except ExportError: + raise + except OSError: + message = "export destination could not be written" + raise ExportWriteError(message) from None + finally: + if temporary is not None: + _unlink_quietly(directory_fd, temporary) + _close_quietly(directory_fd) + return result + + +_CANONICAL_ID = re.compile(r"ag[ctr]1:[0-9a-v]{26}") + + +def _artifact_slug(artifact: ExportArtifact) -> str: + """Return a slug sourced only from structural canonical IDs.""" + canonical_id = ( + artifact.thread_id + if artifact.thread_id is not None and re.fullmatch(r"agt1:[0-9a-v]{26}", artifact.thread_id) + else None + ) + if canonical_id is None and artifact.format == "ndjson": + for line in artifact.text.splitlines(): + try: + row = json.loads(line) + except json.JSONDecodeError, TypeError: + continue + if not isinstance(row, dict): + continue + for key in ("record_id", "content_id"): + candidate = row.get(key) + if isinstance(candidate, str) and _CANONICAL_ID.fullmatch(candidate): + canonical_id = candidate + break + if canonical_id is not None: + break + elif canonical_id is None: + metadata = artifact.text.partition("\n### Body\n")[0] + for label, prefix in (("Record", "agr"), ("Content", "agc")): + match = re.search( + rf"^- {label} ID: (?P{prefix}1:[0-9a-v]{{26}})$", + metadata, + flags=re.MULTILINE, + ) + if match is not None: + canonical_id = match.group("id") + break + return "empty" if canonical_id is None else canonical_id.replace(":", "-") + + +def _default_private_directory() -> pathlib.Path: + """Return the user-private default export directory.""" + data_home = os.environ.get("XDG_DATA_HOME") + if data_home: + return pathlib.Path(data_home) / "agentgrep" / "exports" + return pathlib.Path.home() / ".local" / "share" / "agentgrep" / "exports" + + +def write_private_export( + artifact: ExportArtifact, + directory: str | os.PathLike[str] | None = None, +) -> pathlib.Path: + """Write an artifact under a private collision-free canonical name.""" + private_directory = ( + _default_private_directory() if directory is None else pathlib.Path(directory) + ) + directory_fd = _open_directory(private_directory, create_private=True) + _close_quietly(directory_fd) + extension = "ndjson" if artifact.format == "ndjson" else "md" + basename = f"agentgrep-{_artifact_slug(artifact)}" + index = 1 + while True: + suffix = "" if index == 1 else f"-{index}" + destination = private_directory / f"{basename}{suffix}.{extension}" + try: + return write_export(artifact, destination) + except ExportExistsError: + index += 1 diff --git a/tests/test_conversations.py b/tests/test_conversations.py index 30253b89b..92bb08aab 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -887,3 +887,63 @@ def __iter__(self) -> t.Iterator[SearchRecord]: _ = group_conversation_units(one_shot) assert one_shot.iterations == 1 + + +def test_group_prepared_conversation_units_matches_compatibility_wrapper() -> None: + """Caller-prepared identities produce the established public units.""" + records = ( + _record("late", position=RecordPosition(ordinal=4, quality="source_order")), + _record("early", position=RecordPosition(ordinal=1, quality="source_order")), + ) + prepared = tuple((record, record_identity(record)) for record in records) + + units = conversations.group_prepared_conversation_units(prepared) + + assert _unit_projection(units) == _unit_projection(group_conversation_units(records)) + + +def test_group_prepared_conversation_units_consumes_input_once() -> None: + """Prepared one-shot iterables are neither replayed nor rescanned.""" + record = _record( + "threaded", + position=RecordPosition(ordinal=0, quality="source_order"), + ) + prepared = ((record, record_identity(record)),) + + class OneShotPreparedRecords: + def __init__(self) -> None: + self.iterations = 0 + + def __iter__(self) -> t.Iterator[tuple[SearchRecord, RecordIdentity]]: + self.iterations += 1 + if self.iterations > 1: + message = "prepared records consumed more than once" + raise AssertionError(message) + return iter(prepared) + + one_shot = OneShotPreparedRecords() + + _ = conversations.group_prepared_conversation_units(one_shot) + + assert one_shot.iterations == 1 + + +def test_group_prepared_conversation_units_never_rehashes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Supplied identity bundles remain the only identity work performed.""" + record = _record( + "threaded", + position=RecordPosition(ordinal=0, quality="source_order"), + ) + prepared = ((record, record_identity(record)),) + + def fail_identity(*_args: object, **_kwargs: object) -> t.NoReturn: + pytest.fail("prepared conversation grouping recomputed identity") + + monkeypatch.setattr(conversations, "record_thread_id", fail_identity) + monkeypatch.setattr(conversations, "record_identity", fail_identity) + + units = conversations.group_prepared_conversation_units(prepared) + + assert units[0].records == (record,) diff --git a/tests/test_record_export.py b/tests/test_record_export.py new file mode 100644 index 000000000..c084a8c70 --- /dev/null +++ b/tests/test_record_export.py @@ -0,0 +1,896 @@ +"""Deterministic portable record export tests.""" + +from __future__ import annotations + +import dataclasses +import io +import itertools +import json +import os +import pathlib +import stat +import sys +import typing as t + +import pytest + +import agentgrep.record_export as record_export +from agentgrep.conversations import ConversationFidelity, ConversationUnit +from agentgrep.identity import RecordIdentity +from agentgrep.record_export import ( + ExportArtifact, + ExportEncodingError, + ExportExistsError, + ExportSafetyError, + ExportSelectionError, + ExportWriteError, + render_export, + write_export, + write_private_export, +) +from agentgrep.records import RecordOrigin, RecordPosition, SearchRecord + + +def _record( + text: str, + *, + kind: t.Literal["prompt", "history"] = "prompt", + role: str | None = "user", + session_id: str | None = "session-1", + identity_namespace: str | None = "codex.session", + position: RecordPosition | None = None, + timestamp: str | None = "2026-07-12T12:00:00Z", + model: str | None = "gpt-test", + store: str = "codex.sessions", + path: pathlib.Path = pathlib.Path("session.jsonl"), +) -> SearchRecord: + """Build one export-focused normalized record.""" + return SearchRecord( + kind=kind, + agent="codex", + store=store, + adapter_id="codex.sessions_jsonl.v1", + path=path, + text=text, + title="private display title", + role=role, + timestamp=timestamp, + model=model, + session_id=session_id, + conversation_id=session_id, + metadata={"private-metadata": "must-not-leak"}, + origin=RecordOrigin(cwd="/private/project", branch="private-branch"), + identity_namespace=identity_namespace, + position=position, + ) + + +def _ndjson_rows(artifact: ExportArtifact) -> list[dict[str, object]]: + """Decode one NDJSON artifact into rows.""" + return [json.loads(line) for line in artifact.text.splitlines()] + + +@pytest.mark.parametrize("export_format", ("ndjson", "markdown")) +@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +@pytest.mark.parametrize("record_count", (0, 1, 3), ids=("zero", "one", "many")) +def test_render_export_covers_cardinality_format_and_body_permutations( + export_format: record_export.ExportFormat, + include_bodies: bool, + record_count: int, +) -> None: + """Every renderer permutation produces one self-consistent artifact.""" + records = tuple( + _record( + f"body-{index}", + session_id=f"session-{index}", + position=RecordPosition(ordinal=index, quality="source_order"), + ) + for index in range(record_count) + ) + + artifact = render_export(records, format=export_format, include_bodies=include_bodies) + + assert artifact.format == export_format + assert artifact.selection == "records" + assert artifact.record_count == record_count + assert artifact.thread_id is None + assert artifact.fidelity is None + assert artifact.byte_count == len(artifact.text.encode("utf-8")) + for index in range(record_count): + assert (f"body-{index}" in artifact.text) is include_bodies + if export_format == "ndjson": + assert len(_ndjson_rows(artifact)) == record_count + assert artifact.text.endswith("\n") is (record_count > 0) + else: + assert artifact.text.startswith("# agentgrep record export\n") + + +@pytest.mark.parametrize( + ("kind", "role", "timestamp", "model"), + tuple( + itertools.product( + ("prompt", "history"), + ("user", "assistant", None, ""), + ("2026-07-12T12:00:00Z", None), + ("gpt-test", None), + ), + ), +) +def test_ndjson_render_preserves_allowed_role_kind_and_null_metadata( + kind: t.Literal["prompt", "history"], + role: str | None, + timestamp: str | None, + model: str | None, +) -> None: + """Allowed nullable scalars retain their exact normalized values.""" + record = _record( + "body", + kind=kind, + role=role, + timestamp=timestamp, + model=model, + ) + + row = _ndjson_rows(render_export((record,), format="ndjson", include_bodies=False))[0] + + assert row["kind"] == kind + assert row["role"] == role + assert row["timestamp"] == timestamp + assert row["model"] == model + + +@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +def test_ndjson_render_uses_exact_allowlist(include_bodies: bool) -> None: + """Export rows never inherit the broader search serializer surface.""" + artifact = render_export( + (_record("portable body", position=RecordPosition(ordinal=2, quality="source_order")),), + format="ndjson", + include_bodies=include_bodies, + ) + row = _ndjson_rows(artifact)[0] + expected = { + "schema_version", + "agent", + "store", + "kind", + "role", + "timestamp", + "model", + "content_id", + "record_id", + "record_id_stability", + "thread_id", + } + if include_bodies: + expected.add("text") + + assert set(row) == expected + assert row["schema_version"] == "agentgrep.v1" + assert ("text" in row) is include_bodies + + +def test_ndjson_render_preserves_repeated_content_occurrences() -> None: + """Equal bodies at different source positions remain distinct turns.""" + records = ( + _record("repeat", position=RecordPosition(ordinal=9, quality="source_order")), + _record("repeat", position=RecordPosition(ordinal=2, quality="source_order")), + ) + + rows = _ndjson_rows(render_export(records, format="ndjson", include_bodies=True)) + + assert len(rows) == 2 + assert rows[0]["content_id"] == rows[1]["content_id"] + assert rows[0]["record_id"] != rows[1]["record_id"] + assert [row["text"] for row in rows] == ["repeat", "repeat"] + + +@pytest.mark.parametrize("export_format", ("ndjson", "markdown")) +@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +def test_render_export_bytes_are_stable_under_every_input_permutation( + export_format: record_export.ExportFormat, + include_bodies: bool, +) -> None: + """Scheduler enumeration cannot affect portable artifact bytes.""" + records = ( + _record( + "late-a", + session_id="session-a", + timestamp="2030-01-01T00:00:00Z", + position=RecordPosition(ordinal=4, quality="source_order"), + ), + _record( + "early-a", + session_id="session-a", + timestamp="2020-01-01T00:00:00Z", + position=RecordPosition(ordinal=1, quality="source_order"), + ), + _record( + "only-b", + session_id="session-b", + timestamp=None, + position=RecordPosition(native_id="native-b", quality="native"), + ), + ) + + artifacts = { + render_export( + permutation, + format=export_format, + include_bodies=include_bodies, + ).text.encode("utf-8") + for permutation in itertools.permutations(records) + } + + assert len(artifacts) == 1 + + +def test_ndjson_render_escapes_lone_surrogates_to_valid_utf8() -> None: + """Imperfect source text remains portable through JSON escapes.""" + artifact = render_export( + (_record("before\ud800after"),), + format="ndjson", + include_bodies=True, + ) + + encoded = artifact.text.encode("utf-8") + + assert b"before\\ud800after" in encoded + assert _ndjson_rows(artifact)[0]["text"] == "before\ud800after" + assert artifact.byte_count == len(encoded) + + +@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +def test_markdown_render_rejects_emitted_lone_surrogates(include_bodies: bool) -> None: + """Markdown refuses invalid UTF-8 scalars instead of altering them.""" + record = _record("body\ud800" if include_bodies else "hidden\ud800", model="model\udfff") + + with pytest.raises(ExportEncodingError, match="valid UTF-8") as raised: + render_export( + (record,), + format="markdown", + include_bodies=include_bodies, + ) + + assert "session.jsonl" not in str(raised.value) + assert "/private/project" not in str(raised.value) + + +@pytest.mark.parametrize( + ("body", "expected_fence_length"), + ( + pytest.param("plain", 3, id="no-backticks"), + pytest.param("before ``` after", 4, id="triple"), + pytest.param("before ```` after", 5, id="quadruple"), + pytest.param("before ````````````````` after", 18, id="long-run"), + ), +) +def test_markdown_render_uses_dynamic_backtick_fences( + body: str, + expected_fence_length: int, +) -> None: + """A body can never terminate its own Markdown fence.""" + artifact = render_export( + (_record(body),), + format="markdown", + include_bodies=True, + ) + marker = "\n### Body\n\n" + fenced = artifact.text.split(marker, 1)[1] + opening, rendered_body, closing, _trailer = fenced.split("\n", 3) + + assert opening == "`" * expected_fence_length + "text" + assert rendered_body == body + assert closing == "`" * expected_fence_length + + +def test_render_thread_rejects_null_and_mixed_thread_identity() -> None: + """A thread label requires exactly one non-null canonical unit.""" + threadless = _record("flat", session_id=None, identity_namespace=None) + first = _record("first", session_id="thread-a") + second = _record("second", session_id="thread-b") + + for records in ((threadless,), (first, second), (threadless, first)): + with pytest.raises(ExportSelectionError, match="exactly one observed thread"): + render_export( + records, + format="ndjson", + include_bodies=False, + selection="thread", + ) + + +@pytest.mark.parametrize( + ("records", "expected_fidelity"), + ( + pytest.param( + ( + _record( + "first", + position=RecordPosition(ordinal=0, quality="source_order"), + ), + _record( + "second", + position=RecordPosition(ordinal=1, quality="source_order"), + ), + ), + "source_order", + id="source-order", + ), + pytest.param( + ( + _record( + "root", + position=RecordPosition(native_id="root", quality="native"), + ), + _record( + "child", + position=RecordPosition( + native_id="child", + parent_native_id="root", + quality="native", + ), + ), + ), + "native_tree", + id="native-tree", + ), + pytest.param( + ( + _record( + "one", + position=RecordPosition(native_id="one", quality="native"), + ), + _record( + "two", + position=RecordPosition(native_id="two", quality="native"), + ), + ), + "unordered", + id="unordered", + ), + ), +) +def test_markdown_render_thread_discloses_every_fidelity( + records: tuple[SearchRecord, ...], + expected_fidelity: ConversationFidelity, +) -> None: + """Thread Markdown labels the observed unit without inventing order.""" + artifact = render_export( + records, + format="markdown", + include_bodies=False, + selection="thread", + ) + + assert artifact.selection == "thread" + assert artifact.thread_id is not None + assert artifact.fidelity == expected_fidelity + assert artifact.record_count == 2 + assert artifact.text.startswith("# agentgrep observed thread export\n") + assert f"- Fidelity: {expected_fidelity}\n" in artifact.text + assert f"- Thread ID: {artifact.thread_id}\n" in artifact.text + + +@pytest.mark.parametrize("export_format", ("ndjson", "markdown")) +@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +def test_render_thread_uses_export_order_under_all_input_permutations( + export_format: record_export.ExportFormat, + include_bodies: bool, +) -> None: + """Thread validation does not replace the export-owned total order.""" + late = _record( + "late body", + timestamp="2030-01-01T00:00:00Z", + position=RecordPosition(ordinal=0, quality="source_order"), + ) + early = _record( + "early body", + timestamp="2020-01-01T00:00:00Z", + position=RecordPosition(ordinal=1, quality="source_order"), + ) + + artifacts = tuple( + render_export( + permutation, + format=export_format, + include_bodies=include_bodies, + selection="thread", + ) + for permutation in itertools.permutations((late, early)) + ) + + assert len({artifact.text.encode("utf-8") for artifact in artifacts}) == 1 + text = artifacts[0].text + if export_format == "ndjson": + assert [row["timestamp"] for row in _ndjson_rows(artifacts[0])] == [ + "2020-01-01T00:00:00Z", + "2030-01-01T00:00:00Z", + ] + else: + assert text.index("2020-01-01T00:00:00Z") < text.index("2030-01-01T00:00:00Z") + if include_bodies: + assert text.index("early body") < text.index("late body") + + +@pytest.mark.parametrize("export_format", ("ndjson", "markdown")) +@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +def test_render_export_never_leaks_excluded_record_fields( + export_format: record_export.ExportFormat, + include_bodies: bool, +) -> None: + """Paths, titles, native anchors, origins, and metadata stay private.""" + excluded = ( + "secret-source-name.jsonl", + "private display title", + "secret-native-session", + "/private/project", + "private-branch", + "private-metadata", + "must-not-leak", + "codex.sessions_jsonl.v1", + ) + record = _record( + "portable body", + session_id="secret-native-session", + path=pathlib.Path("secret-source-name.jsonl"), + ) + + artifact = render_export( + (record,), + format=export_format, + include_bodies=include_bodies, + ) + + assert all(value not in artifact.text for value in excluded) + + +@pytest.mark.parametrize("selection", ("records", "thread")) +def test_render_export_prepares_each_record_identity_once( + monkeypatch: pytest.MonkeyPatch, + selection: record_export.ExportSelection, +) -> None: + """Rendering caches the cryptographic identity bundle per record.""" + records = ( + _record("one", position=RecordPosition(ordinal=0, quality="source_order")), + _record("two", position=RecordPosition(ordinal=1, quality="source_order")), + ) + real_record_identity = record_export.record_identity + calls: list[SearchRecord] = [] + + def counting_record_identity(record: SearchRecord) -> record_export.RecordIdentity: + calls.append(record) + return real_record_identity(record) + + monkeypatch.setattr(record_export, "record_identity", counting_record_identity) + + _ = render_export( + records, + format="ndjson", + include_bodies=True, + selection=selection, + ) + + assert calls == list(records) + + +def test_render_thread_reuses_conversation_grouping( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Thread validation and fidelity stay owned by conversations.""" + record = _record( + "one", + position=RecordPosition(ordinal=0, quality="source_order"), + ) + real_group = record_export.group_prepared_conversation_units + calls: list[tuple[tuple[SearchRecord, RecordIdentity], ...]] = [] + + def counting_group( + records: t.Iterable[tuple[SearchRecord, RecordIdentity]], + ) -> tuple[ConversationUnit, ...]: + consumed = tuple(records) + calls.append(consumed) + return real_group(consumed) + + monkeypatch.setattr(record_export, "group_prepared_conversation_units", counting_group) + + _ = render_export( + (record,), + format="markdown", + include_bodies=False, + selection="thread", + ) + + assert len(calls) == 1 + assert [item[0] for item in calls[0]] == [record] + + +def test_render_export_artifact_has_exact_immutable_contract() -> None: + """The frontend-neutral return value stays shallow-frozen and slot-backed.""" + artifact = render_export((), format="ndjson", include_bodies=False) + + assert tuple(field.name for field in dataclasses.fields(artifact)) == ( + "format", + "selection", + "record_count", + "thread_id", + "fidelity", + "text", + "byte_count", + ) + assert not hasattr(artifact, "__dict__") + with pytest.raises(dataclasses.FrozenInstanceError): + t.cast("t.Any", artifact).record_count = 1 + + +def _writer_artifact( + text: str = "portable body", + *, + export_format: record_export.ExportFormat = "ndjson", +) -> ExportArtifact: + """Render one canonical artifact for writer tests.""" + return render_export( + ( + _record( + text, + position=RecordPosition(ordinal=7, quality="source_order"), + ), + ), + format=export_format, + include_bodies=True, + ) + + +def test_write_export_writes_exact_artifact_bytes_without_stdout( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """File output depends only on the rendered artifact.""" + artifact = _writer_artifact("café 🦀") + destination = tmp_path / "artifact.ndjson" + fake_stdout = io.StringIO("unrelated terminal state") + monkeypatch.setattr(sys, "stdout", fake_stdout) + + result = write_export(artifact, destination) + + assert result == destination + assert destination.read_bytes() == artifact.text.encode("utf-8") + assert fake_stdout.getvalue() == "unrelated terminal state" + + +def test_write_export_creates_fresh_private_file(tmp_path: pathlib.Path) -> None: + """A new destination receives the complete artifact at mode 0600.""" + artifact = _writer_artifact() + destination = tmp_path / "fresh.ndjson" + + write_export(artifact, destination) + + assert destination.read_text(encoding="utf-8") == artifact.text + assert stat.S_IMODE(destination.stat().st_mode) == 0o600 + + +def test_write_export_refuses_existing_target_without_modifying_it( + tmp_path: pathlib.Path, +) -> None: + """No-clobber output preserves a pre-existing destination.""" + destination = tmp_path / "existing.ndjson" + destination.write_text("keep me", encoding="utf-8") + + with pytest.raises(ExportExistsError, match="already exists") as raised: + write_export(_writer_artifact(), destination) + + assert destination.read_text(encoding="utf-8") == "keep me" + assert str(destination) not in str(raised.value) + + +def test_write_export_force_atomically_replaces_regular_file(tmp_path: pathlib.Path) -> None: + """Explicit force replaces a regular destination with artifact bytes.""" + artifact = _writer_artifact("replacement") + destination = tmp_path / "replace.ndjson" + destination.write_text("old", encoding="utf-8") + + write_export(artifact, destination, force=True) + + assert destination.read_bytes() == artifact.text.encode("utf-8") + assert stat.S_IMODE(destination.stat().st_mode) == 0o600 + + +@pytest.mark.parametrize("force", (False, True), ids=("no-force", "force")) +def test_write_export_rejects_destination_symlink_without_following_it( + tmp_path: pathlib.Path, + force: bool, +) -> None: + """A final-component symlink can never redirect exported text.""" + target = tmp_path / "source-secret.jsonl" + target.write_text("source bytes", encoding="utf-8") + destination = tmp_path / "export.ndjson" + destination.symlink_to(target) + + with pytest.raises(ExportSafetyError, match="unsafe") as raised: + write_export(_writer_artifact(), destination, force=force) + + assert target.read_text(encoding="utf-8") == "source bytes" + assert destination.is_symlink() + assert str(destination) not in str(raised.value) + assert str(target) not in str(raised.value) + + +def test_write_export_rejects_parent_symlink_traversal(tmp_path: pathlib.Path) -> None: + """No ancestor symlink can redirect a same-directory temporary file.""" + real_parent = tmp_path / "real-private-parent" + real_parent.mkdir() + linked_parent = tmp_path / "linked-private-parent" + linked_parent.symlink_to(real_parent, target_is_directory=True) + destination = linked_parent / "artifact.ndjson" + + with pytest.raises(ExportSafetyError, match="unsafe") as raised: + write_export(_writer_artifact(), destination) + + assert not (real_parent / "artifact.ndjson").exists() + assert str(linked_parent) not in str(raised.value) + + +def test_write_export_rejects_protected_source_alias(tmp_path: pathlib.Path) -> None: + """Normalized path aliases cannot overwrite a selected source.""" + source = tmp_path / "source-private.jsonl" + source.write_text("source bytes", encoding="utf-8") + alias = tmp_path / "nested" / ".." / source.name + + with pytest.raises(ExportSafetyError, match="protected source") as raised: + write_export( + _writer_artifact(), + alias, + force=True, + protected_paths=(source,), + ) + + assert source.read_text(encoding="utf-8") == "source bytes" + assert str(source) not in str(raised.value) + + +def test_write_export_rejects_hard_link_to_protected_source(tmp_path: pathlib.Path) -> None: + """Inode aliases cannot bypass protected source checks under force.""" + source = tmp_path / "source-private.jsonl" + source.write_text("source bytes", encoding="utf-8") + destination = tmp_path / "hard-link.ndjson" + destination.hardlink_to(source) + + with pytest.raises(ExportSafetyError, match="protected source") as raised: + write_export( + _writer_artifact(), + destination, + force=True, + protected_paths=(source,), + ) + + assert source.read_text(encoding="utf-8") == "source bytes" + assert destination.read_text(encoding="utf-8") == "source bytes" + assert str(source) not in str(raised.value) + + +def test_write_export_retries_short_os_writes( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Complete-write semantics tolerate positive short writes.""" + artifact = _writer_artifact("x" * 257) + destination = tmp_path / "short-write.ndjson" + real_write = os.write + write_sizes: list[int] = [] + + def short_write(fd: int, data: bytes | bytearray | memoryview) -> int: + chunk = data[:7] + write_sizes.append(len(chunk)) + return real_write(fd, chunk) + + monkeypatch.setattr(record_export.os, "write", short_write) + + write_export(artifact, destination) + + assert len(write_sizes) > 1 + assert destination.read_bytes() == artifact.text.encode("utf-8") + + +def test_write_export_cleans_temporary_file_after_failure( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed complete write leaves neither destination nor temp debris.""" + destination = tmp_path / "failed.ndjson" + + def fail_write(_fd: int, _data: bytes | bytearray | memoryview) -> t.NoReturn: + raise OSError + + monkeypatch.setattr(record_export.os, "write", fail_write) + + with pytest.raises(ExportWriteError, match="could not be written") as raised: + write_export(_writer_artifact(), destination) + + assert list(tmp_path.iterdir()) == [] + assert str(destination) not in str(raised.value) + assert "synthetic" not in str(raised.value) + + +def test_write_export_fsyncs_file_and_parent_directory( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A successful install durably syncs content and directory metadata.""" + destination = tmp_path / "durable.ndjson" + real_fsync = os.fsync + synced_types: list[str] = [] + + def tracking_fsync(fd: int) -> None: + mode = os.fstat(fd).st_mode + synced_types.append("directory" if stat.S_ISDIR(mode) else "file") + real_fsync(fd) + + monkeypatch.setattr(record_export.os, "fsync", tracking_fsync) + + write_export(_writer_artifact(), destination) + + assert synced_types.count("file") == 1 + assert synced_types.count("directory") == 1 + + +def test_write_export_no_clobber_install_wins_race_safely( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A competitor created after the temp write is never overwritten.""" + destination = tmp_path / "raced.ndjson" + real_link = os.link + + def competing_link( + source: str, + target: str, + *, + src_dir_fd: int | None = None, + dst_dir_fd: int | None = None, + follow_symlinks: bool = True, + ) -> None: + competitor_fd = os.open( + target, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=dst_dir_fd, + ) + try: + _ = os.write(competitor_fd, b"competitor") + finally: + os.close(competitor_fd) + real_link( + source, + target, + src_dir_fd=src_dir_fd, + dst_dir_fd=dst_dir_fd, + follow_symlinks=follow_symlinks, + ) + + monkeypatch.setattr(record_export.os, "link", competing_link) + + with pytest.raises(ExportExistsError, match="already exists"): + write_export(_writer_artifact(), destination) + + assert destination.read_bytes() == b"competitor" + assert [path.name for path in tmp_path.iterdir()] == [destination.name] + + +def test_write_private_export_enforces_directory_mode_and_collision_suffix( + tmp_path: pathlib.Path, +) -> None: + """Private output is 0700/0600 and allocates stable canonical names.""" + directory = tmp_path / "exports" + artifact = _writer_artifact("body-private-token", export_format="markdown") + record_id = _ndjson_rows(_writer_artifact("body-private-token"))[0]["record_id"] + assert isinstance(record_id, str) + slug = record_id.replace(":", "-") + + first = write_private_export(artifact, directory=directory) + second = write_private_export(artifact, directory=directory) + + assert first == directory / f"agentgrep-{slug}.md" + assert second == directory / f"agentgrep-{slug}-2.md" + assert first.read_text(encoding="utf-8") == artifact.text + assert second.read_text(encoding="utf-8") == artifact.text + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + assert stat.S_IMODE(first.stat().st_mode) == 0o600 + assert "body-private-token" not in first.name + assert "private" not in first.name + + +def test_write_private_export_uses_thread_id_slug(tmp_path: pathlib.Path) -> None: + """Observed thread names derive only from their canonical thread ID.""" + artifact = render_export( + ( + _record( + "private thread body", + position=RecordPosition(ordinal=0, quality="source_order"), + ), + ), + format="ndjson", + include_bodies=True, + selection="thread", + ) + assert artifact.thread_id is not None + + destination = write_private_export(artifact, directory=tmp_path / "exports") + + assert destination.name == f"agentgrep-{artifact.thread_id.replace(':', '-')}.ndjson" + assert "private" not in destination.name + + +def test_write_private_export_ignores_noncanonical_thread_id(tmp_path: pathlib.Path) -> None: + """A public artifact cannot turn an arbitrary thread value into a path.""" + artifact = ExportArtifact( + format="ndjson", + selection="thread", + record_count=0, + thread_id="../../private-path", + fidelity="unordered", + text="", + byte_count=0, + ) + directory = tmp_path / "exports" + + destination = write_private_export(artifact, directory=directory) + + assert destination == directory / "agentgrep-empty.ndjson" + + +def test_write_private_export_never_reads_id_shaped_markdown_body( + tmp_path: pathlib.Path, +) -> None: + """Only structural metadata, never record text, may supply a slug.""" + fake_id = "agr1:00000000000000000000000000" + text = f"# export\n\n### Body\n\n```text\n- Record ID: {fake_id}\n```\n" + artifact = ExportArtifact( + format="markdown", + selection="records", + record_count=1, + thread_id=None, + fidelity=None, + text=text, + byte_count=len(text.encode("utf-8")), + ) + + destination = write_private_export(artifact, directory=tmp_path / "exports") + + assert destination.name == "agentgrep-empty.md" + assert fake_id.replace(":", "-") not in destination.name + + +def test_write_private_export_uses_content_id_before_id_shaped_record_body( + tmp_path: pathlib.Path, +) -> None: + """A rendered null record ID cannot make body prose control its name.""" + fake_id = "agr1:00000000000000000000000000" + record = _record( + f"body\n- Record ID: {fake_id}", + session_id=None, + identity_namespace=None, + position=None, + ) + artifact = render_export( + (record,), + format="markdown", + include_bodies=True, + ) + content_id = record_export.record_identity(record).content_id + + destination = write_private_export(artifact, directory=tmp_path / "exports") + + assert destination.name == f"agentgrep-{content_id.replace(':', '-')}.md" + assert fake_id.replace(":", "-") not in destination.name + + +def test_write_export_errors_never_disclose_destination_path(tmp_path: pathlib.Path) -> None: + """Filesystem failures expose stable guidance rather than local paths.""" + destination = tmp_path / "secret-parent" / "secret-artifact.ndjson" + + with pytest.raises(ExportWriteError) as raised: + write_export(_writer_artifact(), destination) + + message = str(raised.value) + assert str(destination) not in message + assert "secret-parent" not in message + assert "secret-artifact" not in message From 3708440c2142607c4f55b51db125bc6cdddfe7ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 08:44:19 -0500 Subject: [PATCH 02/71] agentgrep(fix[export]): Hold private dir fd why: Closing the trusted private-directory descriptor before installation allowed a path replacement to redirect output and bypass the 0700 directory guarantee. what: - Share one descriptor-relative atomic installer across both writers. - Keep the private descriptor through collision allocation and fsync. - Regress a deterministic directory replacement at the trust boundary. --- src/agentgrep/record_export.py | 134 +++++++++++++++++++++------------ tests/test_record_export.py | 31 ++++++++ 2 files changed, 115 insertions(+), 50 deletions(-) diff --git a/src/agentgrep/record_export.py b/src/agentgrep/record_export.py index 0ef3fd127..60edce53b 100644 --- a/src/agentgrep/record_export.py +++ b/src/agentgrep/record_export.py @@ -483,45 +483,20 @@ def _write_all(file_fd: int, payload: bytes) -> None: offset += written -def write_export( - artifact: ExportArtifact, - destination: str | os.PathLike[str], +def _install_export( + payload: bytes, + directory_fd: int, + name: str, *, - force: bool = False, - protected_paths: _ProtectedPaths = (), -) -> pathlib.Path: - """Atomically write an artifact without following destination links. - - Parameters - ---------- - artifact - Fully rendered portable artifact. - destination - Explicit destination file. - force - Whether to replace an existing regular file. - protected_paths - Source paths that the destination must not alias. - - Returns - ------- - pathlib.Path - The caller-supplied destination value. - """ - payload = _artifact_bytes(artifact) - result = pathlib.Path(destination) - absolute = _absolute(result) - if not absolute.name: - message = "export destination is unsafe" - raise ExportSafetyError(message) - - protected = tuple(protected_paths) - _reject_protected_alias(absolute, None, protected) - directory_fd = _open_directory(absolute.parent, create_private=False) + force: bool, + destination: pathlib.Path, + protected_paths: _ProtectedPaths, +) -> None: + """Install artifact bytes relative to one secured directory descriptor.""" temporary: str | None = None try: - existing = _destination_stat(directory_fd, absolute.name) - _reject_protected_alias(absolute, existing, protected) + existing = _destination_stat(directory_fd, name) + _reject_protected_alias(destination, existing, protected_paths) if existing is not None and not force: message = "export destination already exists" raise ExportExistsError(message) @@ -534,11 +509,11 @@ def write_export( _close_quietly(file_fd) if force: - current = _destination_stat(directory_fd, absolute.name) - _reject_protected_alias(absolute, current, protected) + current = _destination_stat(directory_fd, name) + _reject_protected_alias(destination, current, protected_paths) os.replace( temporary, - absolute.name, + name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd, ) @@ -546,7 +521,7 @@ def write_export( try: os.link( temporary, - absolute.name, + name, src_dir_fd=directory_fd, dst_dir_fd=directory_fd, follow_symlinks=False, @@ -565,6 +540,53 @@ def write_export( finally: if temporary is not None: _unlink_quietly(directory_fd, temporary) + + +def write_export( + artifact: ExportArtifact, + destination: str | os.PathLike[str], + *, + force: bool = False, + protected_paths: _ProtectedPaths = (), +) -> pathlib.Path: + """Atomically write an artifact without following destination links. + + Parameters + ---------- + artifact + Fully rendered portable artifact. + destination + Explicit destination file. + force + Whether to replace an existing regular file. + protected_paths + Source paths that the destination must not alias. + + Returns + ------- + pathlib.Path + The caller-supplied destination value. + """ + payload = _artifact_bytes(artifact) + result = pathlib.Path(destination) + absolute = _absolute(result) + if not absolute.name: + message = "export destination is unsafe" + raise ExportSafetyError(message) + + protected = tuple(protected_paths) + _reject_protected_alias(absolute, None, protected) + directory_fd = _open_directory(absolute.parent, create_private=False) + try: + _install_export( + payload, + directory_fd, + absolute.name, + force=force, + destination=absolute, + protected_paths=protected, + ) + finally: _close_quietly(directory_fd) return result @@ -624,15 +646,27 @@ def write_private_export( private_directory = ( _default_private_directory() if directory is None else pathlib.Path(directory) ) - directory_fd = _open_directory(private_directory, create_private=True) - _close_quietly(directory_fd) + payload = _artifact_bytes(artifact) extension = "ndjson" if artifact.format == "ndjson" else "md" basename = f"agentgrep-{_artifact_slug(artifact)}" - index = 1 - while True: - suffix = "" if index == 1 else f"-{index}" - destination = private_directory / f"{basename}{suffix}.{extension}" - try: - return write_export(artifact, destination) - except ExportExistsError: - index += 1 + directory_fd = _open_directory(private_directory, create_private=True) + try: + index = 1 + while True: + suffix = "" if index == 1 else f"-{index}" + destination = private_directory / f"{basename}{suffix}.{extension}" + try: + _install_export( + payload, + directory_fd, + destination.name, + force=False, + destination=_absolute(destination), + protected_paths=(), + ) + except ExportExistsError: + index += 1 + continue + return destination + finally: + _close_quietly(directory_fd) diff --git a/tests/test_record_export.py b/tests/test_record_export.py index c084a8c70..9f94cb116 100644 --- a/tests/test_record_export.py +++ b/tests/test_record_export.py @@ -798,6 +798,37 @@ def test_write_private_export_enforces_directory_mode_and_collision_suffix( assert "private" not in first.name +def test_write_private_export_keeps_secured_directory_when_path_is_replaced( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A path replacement cannot redirect output after its directory is secured.""" + directory = tmp_path / "exports" + secured_directory = tmp_path / "secured-exports" + artifact = _writer_artifact() + real_open_directory = record_export._open_directory + swapped = False + + def swap_after_open(path: pathlib.Path, *, create_private: bool) -> int: + nonlocal swapped + directory_fd = real_open_directory(path, create_private=create_private) + if create_private and not swapped: + directory.rename(secured_directory) + directory.mkdir(mode=0o700) + swapped = True + return directory_fd + + monkeypatch.setattr(record_export, "_open_directory", swap_after_open) + + destination = write_private_export(artifact, directory=directory) + + assert destination.parent == directory + assert list(directory.iterdir()) == [] + installed = tuple(secured_directory.iterdir()) + assert len(installed) == 1 + assert installed[0].read_bytes() == artifact.text.encode("utf-8") + + def test_write_private_export_uses_thread_id_slug(tmp_path: pathlib.Path) -> None: """Observed thread names derive only from their canonical thread ID.""" artifact = render_export( From df56d83c1c2030349beda1d0406d243f4f1aea33 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 08:46:07 -0500 Subject: [PATCH 03/71] agentgrep(fix[export]): Validate artifacts why: ExportArtifact is publicly constructible, so its type hints cannot protect writer boundaries from forged format or selection values. what: - Validate exact format and selection literals before path effects. - Share the validator across explicit and private writers. - Cover both writers with forged-artifact regressions. --- src/agentgrep/record_export.py | 14 +++++++---- tests/test_record_export.py | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/agentgrep/record_export.py b/src/agentgrep/record_export.py index 60edce53b..43a64276f 100644 --- a/src/agentgrep/record_export.py +++ b/src/agentgrep/record_export.py @@ -294,8 +294,14 @@ def render_export( ) -def _artifact_bytes(artifact: ExportArtifact) -> bytes: - """Return validated bytes for a rendered artifact.""" +def _validated_artifact_bytes(artifact: ExportArtifact) -> bytes: + """Validate the public artifact contract and return its exact bytes.""" + if artifact.format not in ("ndjson", "markdown"): + message = "unsupported export format" + raise ExportFormatError(message) + if artifact.selection not in ("records", "thread"): + message = "unsupported export selection" + raise ExportSelectionError(message) try: payload = artifact.text.encode("utf-8") except UnicodeEncodeError: @@ -567,7 +573,7 @@ def write_export( pathlib.Path The caller-supplied destination value. """ - payload = _artifact_bytes(artifact) + payload = _validated_artifact_bytes(artifact) result = pathlib.Path(destination) absolute = _absolute(result) if not absolute.name: @@ -646,7 +652,7 @@ def write_private_export( private_directory = ( _default_private_directory() if directory is None else pathlib.Path(directory) ) - payload = _artifact_bytes(artifact) + payload = _validated_artifact_bytes(artifact) extension = "ndjson" if artifact.format == "ndjson" else "md" basename = f"agentgrep-{_artifact_slug(artifact)}" directory_fd = _open_directory(private_directory, create_private=True) diff --git a/tests/test_record_export.py b/tests/test_record_export.py index 9f94cb116..5a7214a24 100644 --- a/tests/test_record_export.py +++ b/tests/test_record_export.py @@ -21,6 +21,7 @@ ExportArtifact, ExportEncodingError, ExportExistsError, + ExportFormatError, ExportSafetyError, ExportSelectionError, ExportWriteError, @@ -540,6 +541,48 @@ def _writer_artifact( ) +@pytest.mark.parametrize("private", (False, True), ids=("explicit", "private")) +def test_export_writers_reject_forged_format_without_file_side_effects( + tmp_path: pathlib.Path, + private: bool, +) -> None: + """Writer boundaries reject artifacts with non-contract format values.""" + artifact = dataclasses.replace( + _writer_artifact(), + format=t.cast("record_export.ExportFormat", "json"), + ) + destination = tmp_path / ("exports" if private else "artifact.ndjson") + + with pytest.raises(ExportFormatError, match="unsupported export format"): + if private: + write_private_export(artifact, directory=destination) + else: + write_export(artifact, destination) + + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("private", (False, True), ids=("explicit", "private")) +def test_export_writers_reject_forged_selection_without_file_side_effects( + tmp_path: pathlib.Path, + private: bool, +) -> None: + """Writer boundaries reject artifacts with non-contract selection values.""" + artifact = dataclasses.replace( + _writer_artifact(), + selection=t.cast("record_export.ExportSelection", "conversation"), + ) + destination = tmp_path / ("exports" if private else "artifact.ndjson") + + with pytest.raises(ExportSelectionError, match="unsupported export selection"): + if private: + write_private_export(artifact, directory=destination) + else: + write_export(artifact, destination) + + assert list(tmp_path.iterdir()) == [] + + def test_write_export_writes_exact_artifact_bytes_without_stdout( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From 2421cdced004fbcca611898c9e279fc4c86b9f25 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 08:48:16 -0500 Subject: [PATCH 04/71] agentgrep(refactor[export]): Hide prepared seam why: Identity-prepared conversation grouping exists only to avoid duplicate export hashing and should not become unchecked public API. what: - Make the prepared grouping seam package-private. - Keep the public compatibility wrapper and its one-shot behavior. - Retain private-seam compatibility and no-rehash regressions. --- src/agentgrep/conversations.py | 5 ++--- src/agentgrep/record_export.py | 6 ++++-- tests/test_conversations.py | 19 +++++++++++++------ tests/test_record_export.py | 8 ++++---- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/agentgrep/conversations.py b/src/agentgrep/conversations.py index e2071dcf1..b9568e25b 100644 --- a/src/agentgrep/conversations.py +++ b/src/agentgrep/conversations.py @@ -15,7 +15,6 @@ "ConversationFidelity", "ConversationUnit", "group_conversation_units", - "group_prepared_conversation_units", ) type ConversationFidelity = t.Literal["native_tree", "source_order", "unordered"] @@ -408,7 +407,7 @@ def _build_conversation_unit( ) -def group_prepared_conversation_units( +def _group_prepared_conversation_units( records: cabc.Iterable[tuple[SearchRecord, RecordIdentity]], ) -> tuple[ConversationUnit, ...]: """Group records whose canonical identities are already prepared. @@ -468,4 +467,4 @@ def prepare() -> cabc.Iterator[tuple[SearchRecord, RecordIdentity]]: continue yield record, record_identity(record, prepared_thread_id=thread_id) - return group_prepared_conversation_units(prepare()) + return _group_prepared_conversation_units(prepare()) diff --git a/src/agentgrep/record_export.py b/src/agentgrep/record_export.py index 43a64276f..9d408a053 100644 --- a/src/agentgrep/record_export.py +++ b/src/agentgrep/record_export.py @@ -13,7 +13,7 @@ import stat import typing as t -from agentgrep.conversations import ConversationFidelity, group_prepared_conversation_units +from agentgrep.conversations import ConversationFidelity, _group_prepared_conversation_units from agentgrep.identity import RecordIdentity, record_identity from agentgrep.records import SCHEMA_VERSION, SearchRecord @@ -265,7 +265,9 @@ def render_export( thread_id: str | None = None fidelity: ConversationFidelity | None = None if selection == "thread": - units = group_prepared_conversation_units((item.record, item.identity) for item in prepared) + units = _group_prepared_conversation_units( + (item.record, item.identity) for item in prepared + ) if len(units) != 1 or len(units[0].records) != len(selected): message = "thread export requires exactly one observed thread" raise ExportSelectionError(message) diff --git a/tests/test_conversations.py b/tests/test_conversations.py index 92bb08aab..793d72c44 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -160,6 +160,13 @@ def test_conversations_module_is_available() -> None: assert importlib.util.find_spec("agentgrep.conversations") is not None +def test_prepared_conversation_grouping_is_package_private() -> None: + """The identity-reuse seam is internal rather than public API.""" + assert "group_prepared_conversation_units" not in conversations.__all__ + assert "_group_prepared_conversation_units" not in conversations.__all__ + assert not hasattr(conversations, "group_prepared_conversation_units") + + def test_conversation_unit_has_exact_frozen_tuple_contract() -> None: """The conversation value has the reviewed shallow-frozen tuple shape.""" record = _record("hello", position=RecordPosition(ordinal=0, quality="source_order")) @@ -889,7 +896,7 @@ def __iter__(self) -> t.Iterator[SearchRecord]: assert one_shot.iterations == 1 -def test_group_prepared_conversation_units_matches_compatibility_wrapper() -> None: +def test_private_group_prepared_conversation_units_matches_public_wrapper() -> None: """Caller-prepared identities produce the established public units.""" records = ( _record("late", position=RecordPosition(ordinal=4, quality="source_order")), @@ -897,12 +904,12 @@ def test_group_prepared_conversation_units_matches_compatibility_wrapper() -> No ) prepared = tuple((record, record_identity(record)) for record in records) - units = conversations.group_prepared_conversation_units(prepared) + units = conversations._group_prepared_conversation_units(prepared) assert _unit_projection(units) == _unit_projection(group_conversation_units(records)) -def test_group_prepared_conversation_units_consumes_input_once() -> None: +def test_private_group_prepared_conversation_units_consumes_input_once() -> None: """Prepared one-shot iterables are neither replayed nor rescanned.""" record = _record( "threaded", @@ -923,12 +930,12 @@ def __iter__(self) -> t.Iterator[tuple[SearchRecord, RecordIdentity]]: one_shot = OneShotPreparedRecords() - _ = conversations.group_prepared_conversation_units(one_shot) + _ = conversations._group_prepared_conversation_units(one_shot) assert one_shot.iterations == 1 -def test_group_prepared_conversation_units_never_rehashes( +def test_private_group_prepared_conversation_units_never_rehashes( monkeypatch: pytest.MonkeyPatch, ) -> None: """Supplied identity bundles remain the only identity work performed.""" @@ -944,6 +951,6 @@ def fail_identity(*_args: object, **_kwargs: object) -> t.NoReturn: monkeypatch.setattr(conversations, "record_thread_id", fail_identity) monkeypatch.setattr(conversations, "record_identity", fail_identity) - units = conversations.group_prepared_conversation_units(prepared) + units = conversations._group_prepared_conversation_units(prepared) assert units[0].records == (record,) diff --git a/tests/test_record_export.py b/tests/test_record_export.py index 5a7214a24..eb8a81563 100644 --- a/tests/test_record_export.py +++ b/tests/test_record_export.py @@ -474,15 +474,15 @@ def counting_record_identity(record: SearchRecord) -> record_export.RecordIdenti assert calls == list(records) -def test_render_thread_reuses_conversation_grouping( +def test_render_thread_reuses_private_conversation_grouping( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Thread validation and fidelity stay owned by conversations.""" + """Thread validation reuses the conversations package-private seam.""" record = _record( "one", position=RecordPosition(ordinal=0, quality="source_order"), ) - real_group = record_export.group_prepared_conversation_units + real_group = record_export._group_prepared_conversation_units calls: list[tuple[tuple[SearchRecord, RecordIdentity], ...]] = [] def counting_group( @@ -492,7 +492,7 @@ def counting_group( calls.append(consumed) return real_group(consumed) - monkeypatch.setattr(record_export, "group_prepared_conversation_units", counting_group) + monkeypatch.setattr(record_export, "_group_prepared_conversation_units", counting_group) _ = render_export( (record,), From 285916d672f41c6af34b2817ef534f2fd88838b5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 09:09:29 -0500 Subject: [PATCH 05/71] agentgrep(feat[export]): Add headless CLI why: Deterministic record rendering and safe persistence need a bounded, non-interactive command that preserves search semantics without loading the export stack on help paths. what: - Add typed export parsing, limits, output policy, and facade dispatch. - Render matching records to complete stdout writes or protected files. - Cover parser permutations, fixture-store execution, and path-free errors. --- src/agentgrep/__init__.py | 6 + src/agentgrep/cli/parser.py | 165 ++++++++++++- src/agentgrep/cli/render.py | 95 +++++++- tests/test_cli_export.py | 449 ++++++++++++++++++++++++++++++++++++ tests/test_record_export.py | 54 ++--- 5 files changed, 740 insertions(+), 29 deletions(-) create mode 100644 tests/test_cli_export.py diff --git a/src/agentgrep/__init__.py b/src/agentgrep/__init__.py index 0c1725e24..e00ddbd25 100644 --- a/src/agentgrep/__init__.py +++ b/src/agentgrep/__init__.py @@ -501,6 +501,8 @@ def main(argv: cabc.Sequence[str] | None = None) -> int: parsed = parse_args(argv) if parsed is None: return 0 + if isinstance(parsed, ExportArgs): + return run_export_command(parsed) if isinstance(parsed, GrepArgs): return run_grep_command(parsed) if isinstance(parsed, SearchArgs): @@ -562,6 +564,7 @@ def main(argv: cabc.Sequence[str] | None = None) -> int: ) from agentgrep.cli.parser import ( # noqa: E402 (re-exports must follow main definition) CaseMode, + ExportArgs, FindArgs, FindPatternMode, FindTypeFilter, @@ -587,6 +590,7 @@ def main(argv: cabc.Sequence[str] | None = None) -> int: format_grep_record, print_find_results, print_grep_results, + run_export_command, run_find_command, run_grep_command, run_search_command, @@ -647,6 +651,7 @@ def main(argv: cabc.Sequence[str] | None = None) -> int: "DiscoveryVersionDetail", "EnvelopeFactory", "EnvelopePayload", + "ExportArgs", "FilterCompletedPayload", "FilterRequestedPayload", "FindArgs", @@ -880,6 +885,7 @@ def main(argv: cabc.Sequence[str] | None = None) -> int: "record_matches_scope", "resolve_codex_sqlite_root", "resolve_env_root", + "run_export_command", "run_find_command", "run_find_query", "run_grep_command", diff --git a/src/agentgrep/cli/parser.py b/src/agentgrep/cli/parser.py index 4fc22b2df..2cb4e43d5 100644 --- a/src/agentgrep/cli/parser.py +++ b/src/agentgrep/cli/parser.py @@ -1,7 +1,7 @@ """argparse subcommands and arg-parsing entry points for agentgrep. This module owns the CLI grammar: the root parser, each subparser -(``grep``, ``find``, ``ui``), the typed argument dataclasses +(``grep``, ``find``, ``search``, ``export``, ``ui``), the typed argument dataclasses returned by :func:`parse_args`, and the helpers that resolve color mode and inject default subcommands. @@ -52,6 +52,7 @@ if t.TYPE_CHECKING: from agentgrep.query import CompiledQuery, FieldEqNode, QueryNode + from agentgrep.record_export import ExportFormat CaseMode = t.Literal["smart", "ignore", "respect"] PatternMode = t.Literal["regex", "fixed", "word"] @@ -119,6 +120,7 @@ def _normalize_args_conversation_limit( __all__ = [ "CaseMode", + "ExportArgs", "FindArgs", "FindPatternMode", "FindTypeFilter", @@ -461,6 +463,23 @@ def __post_init__(self) -> None: ) +@dataclasses.dataclass(slots=True) +class ExportArgs: + """Typed arguments for ``agentgrep export``.""" + + terms: tuple[str, ...] + agents: tuple[AgentName, ...] + scope: SearchScope + case_sensitive: bool + limit: int + format: ExportFormat + output: str + force: bool + include_bodies: bool + compiled: CompiledQuery | None = None + raw_query: str = "" + + @dataclasses.dataclass(slots=True) class ParserBundle: """CLI parsers used for root and subcommand help. @@ -481,6 +500,7 @@ class ParserBundle: """ parser: argparse.ArgumentParser + export_parser: argparse.ArgumentParser find_parser: argparse.ArgumentParser grep_parser: argparse.ArgumentParser search_parser: argparse.ArgumentParser @@ -603,6 +623,66 @@ def create_parser( ) subparsers = parser.add_subparsers(dest="command") + export_parser = subparsers.add_parser( + "export", + help="Export matching records as NDJSON or Markdown", + description=( + "Export matching normalized records deterministically without modifying source stores." + ), + formatter_class=formatter_class, + color=color_mode != "never", + ) + add_common_agent_options(export_parser) + _ = export_parser.add_argument( + "terms", + nargs="*", + metavar="TERM", + help="Search terms (combined as AND by default)", + ) + _ = export_parser.add_argument( + "--scope", + choices=["prompts", "conversations", "all"], + dest="scope", + help="Search scope: prompts, conversations, or all (default: prompts)", + ) + _ = export_parser.add_argument( + "--case-sensitive", + action="store_true", + help="Force case-sensitive matching", + ) + _ = export_parser.add_argument( + "--limit", + type=int, + default=100, + metavar="N", + help="Limit exported records to 1-1000 (default: %(default)s)", + ) + _ = export_parser.add_argument( + "--format", + choices=["ndjson", "markdown"], + default="ndjson", + help="Export format (default: %(default)s)", + ) + _ = export_parser.add_argument( + "-o", + "--output", + default="-", + metavar="FILE", + help="Write to FILE, or - for stdout (default: -)", + ) + _ = export_parser.add_argument( + "--force", + action="store_true", + help="Replace an existing regular output file", + ) + _ = export_parser.add_argument( + "--no-bodies", + dest="include_bodies", + action="store_false", + default=True, + help="Exclude record text from the export", + ) + grep_parser = subparsers.add_parser( "grep", help="Content search with rg/ag-shaped flags and output", @@ -1008,6 +1088,7 @@ def create_parser( return ParserBundle( parser=parser, + export_parser=export_parser, find_parser=find_parser, grep_parser=grep_parser, search_parser=search_parser, @@ -1121,6 +1202,31 @@ def _find_explicit_flags(namespace: argparse.Namespace) -> dict[str, str]: return flags +def _export_explicit_flags(namespace: argparse.Namespace) -> dict[str, str]: + """Map query-field names to colliding explicit export flags.""" + flags: dict[str, str] = {} + if t.cast("list[str]", namespace.agent): + flags["agent"] = "--agent" + if t.cast("str | None", namespace.scope) is not None: + flags["scope"] = "--scope" + return flags + + +def _effective_export_scope( + namespace: argparse.Namespace, + *, + user_ast: QueryNode | None, +) -> SearchScope: + """Return export's coarse scope after query-language reconciliation.""" + explicit = t.cast("SearchScope | None", namespace.scope) + base_scope: SearchScope = "prompts" if explicit is None else explicit + if user_ast is None: + return base_scope + from agentgrep.query import scope_widened_for_ast + + return scope_widened_for_ast(user_ast, base_scope) + + def _base_search_scope(namespace: argparse.Namespace) -> SearchScope: """Return the interactive scope before query predicates widen discovery.""" explicit = t.cast("SearchScope | None", namespace.scope) @@ -1417,7 +1523,7 @@ def _check_for_mangled_field_predicate( def parse_args( argv: cabc.Sequence[str] | None = None, -) -> FindArgs | UIArgs | GrepArgs | SearchArgs | None: +) -> ExportArgs | FindArgs | UIArgs | GrepArgs | SearchArgs | None: """Parse CLI arguments into typed dataclasses.""" color_mode = normalize_color_mode(argv) effective_argv = list(argv) if argv is not None else list(sys.argv[1:]) @@ -1442,6 +1548,19 @@ def parse_args( ) agents = parse_agents(t.cast("list[str]", namespace.agent)) + if command == "export": + terms = t.cast("list[str]", namespace.terms) + if not terms: + with configured_color_environment(color_mode): + bundle.export_parser.print_help() + return None + return _build_export_args( + namespace, + agents=agents, + color_mode=color_mode, + bundle=bundle, + ) + output_mode = parse_output_mode(namespace) if command == "grep": @@ -1531,6 +1650,48 @@ def parse_args( ) +def _build_export_args( + namespace: argparse.Namespace, + *, + agents: tuple[AgentName, ...], + color_mode: ColorMode, + bundle: ParserBundle, +) -> ExportArgs: + """Build :class:`ExportArgs` from a parsed argparse namespace.""" + limit = t.cast("int", namespace.limit) + if limit < 1 or limit > 1000: + with configured_color_environment(color_mode): + bundle.export_parser.error("--limit must be between 1 and 1000") + output = t.cast("str", namespace.output) + force = t.cast("bool", namespace.force) + if force and output == "-": + with configured_color_environment(color_mode): + bundle.export_parser.error("--force requires a file output") + + terms_list = t.cast("list[str]", namespace.terms) + compiled, residual_terms, user_ast, _diagnostics = _maybe_compile_query( + terms_list, + bundle=bundle, + color_mode=color_mode, + subparser=bundle.export_parser, + explicit_flags=_export_explicit_flags(namespace), + case_sensitive=t.cast("bool", namespace.case_sensitive), + ) + return ExportArgs( + terms=residual_terms, + agents=agents, + scope=_effective_export_scope(namespace, user_ast=user_ast), + case_sensitive=t.cast("bool", namespace.case_sensitive), + limit=limit, + format=t.cast("ExportFormat", namespace.format), + output=output, + force=force, + include_bodies=t.cast("bool", namespace.include_bodies), + compiled=compiled, + raw_query=" ".join(terms_list), + ) + + def _build_grep_args( namespace: argparse.Namespace, *, diff --git a/src/agentgrep/cli/render.py b/src/agentgrep/cli/render.py index 403539aee..6af3adcb1 100644 --- a/src/agentgrep/cli/render.py +++ b/src/agentgrep/cli/render.py @@ -9,16 +9,19 @@ from __future__ import annotations +import contextlib import dataclasses import json +import os import pathlib import sys from agentgrep import run_ui from agentgrep._engine import iter_find_events, iter_search_events, run_search_result +from agentgrep._engine.orchestration import run_search_query from agentgrep._query_gate import UnregisteredFieldToken from agentgrep._text import AnsiColors, format_display_path -from agentgrep.cli.parser import FindArgs, GrepArgs, SearchArgs, UIArgs +from agentgrep.cli.parser import ExportArgs, FindArgs, GrepArgs, SearchArgs, UIArgs from agentgrep.cli.renderers import ( GrepSummary, _compile_search_patterns, @@ -79,6 +82,7 @@ "iter_match_lines", "print_find_results", "print_grep_results", + "run_export_command", "run_find_command", "run_grep_command", "run_search_command", @@ -131,6 +135,95 @@ def _launch_ui( raise SystemExit(str(error)) from None +def _write_export_stdout(text: str) -> None: + """Write and flush every export byte, including after short writes.""" + payload = text.encode("utf-8") + buffer = getattr(sys.stdout, "buffer", None) + if buffer is None: + offset = 0 + while offset < len(text): + written = sys.stdout.write(text[offset:]) + if written <= 0: + raise OSError + offset += written + else: + offset = 0 + while offset < len(payload): + written = buffer.write(payload[offset:]) + if written is None or written <= 0: + raise OSError + offset += written + sys.stdout.flush() + + +def _write_export_error(message: str) -> None: + """Emit one path-free export diagnostic without masking the failure.""" + with contextlib.suppress(OSError, ValueError): + sys.stderr.write(f"error: {message}\n") + sys.stderr.flush() + + +def _silence_broken_stdout() -> None: + """Prevent interpreter shutdown from flushing a failed stdout again.""" + with contextlib.suppress(AttributeError, OSError, ValueError): + stdout_fd = sys.stdout.fileno() + null_fd = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(null_fd, stdout_fd) + finally: + os.close(null_fd) + + +def run_export_command(args: ExportArgs) -> int: + """Execute ``agentgrep export`` over the shared search engine.""" + from agentgrep.record_export import ExportError, render_export, write_export + + query = SearchQuery( + terms=args.terms, + scope=args.scope, + any_term=False, + regex=False, + case_sensitive=args.case_sensitive, + agents=args.agents, + limit=args.limit, + compiled=args.compiled, + ) + try: + records = run_search_query( + pathlib.Path.home(), + query, + progress=noop_search_progress(), + control=SearchControl(), + ) + except OSError: + _write_export_error("export source could not be read") + return 2 + try: + artifact = render_export( + records, + format=args.format, + include_bodies=args.include_bodies, + ) + if args.output == "-": + _write_export_stdout(artifact.text) + else: + _ = write_export( + artifact, + args.output, + force=args.force, + protected_paths=(record.path for record in records), + ) + except ExportError as exc: + _write_export_error(str(exc)) + return 2 + except OSError, UnicodeError: + if args.output == "-": + _silence_broken_stdout() + _write_export_error("export output could not be written") + return 2 + return 0 if records else 1 + + def print_find_results(records: list[FindRecord], args: FindArgs) -> None: """Emit find results in the requested format. diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py new file mode 100644 index 000000000..565579a0e --- /dev/null +++ b/tests/test_cli_export.py @@ -0,0 +1,449 @@ +"""Functional tests for the dedicated headless export command.""" + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys +import typing as t + +import pytest + +import agentgrep +import agentgrep.cli.render as cli_render + + +def _write_jsonl(path: pathlib.Path, rows: list[object]) -> None: + """Write one fixture store as newline-delimited JSON.""" + path.parent.mkdir(parents=True, exist_ok=True) + _ = path.write_text( + "".join(f"{json.dumps(row)}\n" for row in rows), + encoding="utf-8", + ) + + +@pytest.fixture +def export_home(tmp_path: pathlib.Path) -> pathlib.Path: + """Return a home directory containing deterministic Codex prompt rows.""" + home = tmp_path / "home" + _write_jsonl( + home / ".codex" / "history.jsonl", + [ + { + "session_id": "session-b", + "ts": 1_700_000_002, + "text": "bliss second prompt", + }, + { + "session_id": "session-a", + "ts": 1_700_000_001, + "text": "bliss first prompt", + }, + { + "session_id": "session-c", + "ts": 1_700_000_003, + "text": "unrelated prompt", + }, + ], + ) + return home + + +def _run_export_cli( + home: pathlib.Path, + *args: str, +) -> subprocess.CompletedProcess[str]: + """Run the installed export command against one isolated fixture home.""" + return subprocess.run( + [sys.executable, "-m", "agentgrep", "export", *args], + capture_output=True, + text=True, + check=False, + env=_export_env(home), + ) + + +def _export_env(home: pathlib.Path) -> dict[str, str]: + """Return an isolated subprocess environment for one fixture home.""" + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "CODEX_HOME": str(home / ".codex"), + "NO_COLOR": "1", + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local" / "share"), + "XDG_STATE_HOME": str(home / ".local" / "state"), + }, + ) + return env + + +@pytest.mark.parametrize("output_args", [(), ("-o", "-")]) +def test_export_ndjson_writes_default_and_explicit_stdout( + export_home: pathlib.Path, + output_args: tuple[str, ...], +) -> None: + """NDJSON stdout emits one canonical body-inclusive row per match.""" + completed = _run_export_cli( + export_home, + "bliss", + "--agent", + "codex", + *output_args, + ) + + assert completed.returncode == 0, completed.stderr + rows = [json.loads(line) for line in completed.stdout.splitlines()] + assert [row["text"] for row in rows] == [ + "bliss first prompt", + "bliss second prompt", + ] + assert {row["agent"] for row in rows} == {"codex"} + assert completed.stderr == "" + + +def test_export_ndjson_no_bodies_omits_text_field(export_home: pathlib.Path) -> None: + """The body opt-out removes text without changing selected records.""" + completed = _run_export_cli( + export_home, + "bliss", + "--agent", + "codex", + "--no-bodies", + ) + + assert completed.returncode == 0, completed.stderr + rows = [json.loads(line) for line in completed.stdout.splitlines()] + assert len(rows) == 2 + assert all("text" not in row for row in rows) + assert completed.stderr == "" + + +def test_export_markdown_writes_stdout(export_home: pathlib.Path) -> None: + """Markdown stdout uses the approved deterministic records renderer.""" + completed = _run_export_cli( + export_home, + "bliss", + "--agent", + "codex", + "--format", + "markdown", + ) + + assert completed.returncode == 0, completed.stderr + assert completed.stdout.startswith("# agentgrep record export\n") + assert "- Record count: 2" in completed.stdout + assert "bliss first prompt" in completed.stdout + assert "bliss second prompt" in completed.stdout + assert completed.stderr == "" + + +def test_export_writes_explicit_file_without_stdout( + export_home: pathlib.Path, + tmp_path: pathlib.Path, +) -> None: + """Explicit output delegates the exact artifact to the safe writer.""" + destination = tmp_path / "records.ndjson" + + completed = _run_export_cli( + export_home, + "bliss", + "--agent", + "codex", + "-o", + str(destination), + ) + + assert completed.returncode == 0, completed.stderr + rows = [json.loads(line) for line in destination.read_text(encoding="utf-8").splitlines()] + assert [row["text"] for row in rows] == [ + "bliss first prompt", + "bliss second prompt", + ] + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_export_file_refusal_and_explicit_force( + export_home: pathlib.Path, + tmp_path: pathlib.Path, +) -> None: + """Existing file output is preserved unless replacement is explicit.""" + destination = tmp_path / "records.ndjson" + _ = destination.write_text("keep me\n", encoding="utf-8") + + refused = _run_export_cli( + export_home, + "bliss", + "--agent", + "codex", + "-o", + str(destination), + ) + + assert refused.returncode == 2 + assert destination.read_text(encoding="utf-8") == "keep me\n" + assert refused.stdout == "" + assert "already exists" in refused.stderr + assert str(destination) not in refused.stderr + assert str(export_home) not in refused.stderr + + replaced = _run_export_cli( + export_home, + "bliss", + "--agent", + "codex", + "-o", + str(destination), + "--force", + ) + + assert replaced.returncode == 0, replaced.stderr + assert "bliss first prompt" in destination.read_text(encoding="utf-8") + assert replaced.stdout == "" + assert replaced.stderr == "" + + +def test_export_protects_every_selected_record_source_path( + export_home: pathlib.Path, +) -> None: + """A later selected source cannot be replaced even with ``--force``.""" + source = export_home / ".codex" / "sessions" / "rollout-2025-04-21-selected-source.json" + source.parent.mkdir(parents=True, exist_ok=True) + original = json.dumps( + { + "session": { + "id": "selected-source-session", + "timestamp": "2025-04-21T00:00:00Z", + }, + "items": [ + { + "id": "selected-source-item", + "role": "user", + "type": "message", + "content": "bliss selected source prompt", + }, + ], + }, + ) + _ = source.write_text(original, encoding="utf-8") + + completed = _run_export_cli( + export_home, + "bliss", + "--agent", + "codex", + "--scope", + "all", + "-o", + str(source), + "--force", + ) + + assert completed.returncode == 2 + assert source.read_text(encoding="utf-8") == original + assert completed.stdout == "" + assert "protected source" in completed.stderr + assert str(source) not in completed.stderr + assert str(export_home) not in completed.stderr + assert "Traceback" not in completed.stderr + + +def test_export_zero_matches_uses_search_exit_status(export_home: pathlib.Path) -> None: + """An empty NDJSON selection emits no rows and exits with no-match status.""" + completed = _run_export_cli( + export_home, + "absent-export-query", + "--agent", + "codex", + ) + + assert completed.returncode == 1 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_export_stdout_is_deterministic_across_reruns(export_home: pathlib.Path) -> None: + """Repeated reads of unchanged stores produce byte-identical stdout.""" + first = _run_export_cli(export_home, "bliss", "--agent", "codex") + second = _run_export_cli(export_home, "bliss", "--agent", "codex") + + assert first.returncode == second.returncode == 0 + assert first.stdout.encode() == second.stdout.encode() + assert first.stderr == second.stderr == "" + + +def test_export_invalid_markdown_text_is_path_free( + tmp_path: pathlib.Path, +) -> None: + """Unencodable Markdown content reports only the typed export failure.""" + home = tmp_path / "private-home" + _write_jsonl( + home / ".codex" / "history.jsonl", + [ + { + "session_id": "invalid-markdown", + "ts": 1_700_000_001, + "text": "bliss invalid \ud800 markdown", + }, + ], + ) + + completed = _run_export_cli( + home, + "bliss", + "--agent", + "codex", + "--format", + "markdown", + ) + + assert completed.returncode == 2 + assert completed.stdout == "" + assert "not valid UTF-8" in completed.stderr + assert str(home) not in completed.stderr + assert "Traceback" not in completed.stderr + + +def test_export_search_io_failure_is_path_free( + export_home: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Store I/O errors are sanitized when permissions cannot be tested portably.""" + private_path = export_home / ".codex" / "history.jsonl" + + def fail_search(*_args: object, **_kwargs: object) -> t.NoReturn: + message = f"could not read {private_path}" + raise OSError(message) + + monkeypatch.setattr(cli_render, "run_search_query", fail_search) + + result = agentgrep.run_export_command(_parsed_export_args()) + + assert result == 2 + error = capsys.readouterr().err + assert "export source could not be read" in error + assert str(private_path) not in error + assert str(export_home) not in error + assert "Traceback" not in error + + +class _ShortWriteBuffer: + """Binary stream that accepts only a bounded prefix per write.""" + + def __init__(self) -> None: + self.payload = bytearray() + self.calls = 0 + + def write(self, payload: bytes) -> int: + """Accept at most seven bytes from one write request.""" + size = min(7, len(payload)) + self.payload.extend(payload[:size]) + self.calls += 1 + return size + + +class _BrokenWriteBuffer: + """Binary stream that fails before accepting output.""" + + def write(self, _payload: bytes) -> int: + """Raise the pipe failure surfaced by a closed downstream reader.""" + raise BrokenPipeError + + +class _BinaryStdout: + """Minimal text facade exposing a binary stdout buffer.""" + + def __init__(self, buffer: _ShortWriteBuffer | _BrokenWriteBuffer) -> None: + self.buffer = buffer + self.flush_calls = 0 + + def flush(self) -> None: + """Record a command-level stdout flush.""" + self.flush_calls += 1 + + +def _parsed_export_args() -> agentgrep.ExportArgs: + """Build real typed arguments for direct command execution tests.""" + parsed = agentgrep.parse_args(["export", "bliss", "--agent", "codex"]) + assert isinstance(parsed, agentgrep.ExportArgs) + return parsed + + +def test_export_stdout_retries_positive_short_writes_and_flushes( + export_home: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stdout receives every artifact byte before one final flush.""" + monkeypatch.setenv("HOME", str(export_home)) + monkeypatch.setenv("CODEX_HOME", str(export_home / ".codex")) + buffer = _ShortWriteBuffer() + stdout = _BinaryStdout(buffer) + monkeypatch.setattr(sys, "stdout", t.cast("t.Any", stdout)) + + result = agentgrep.run_export_command(_parsed_export_args()) + + rows = [json.loads(line) for line in buffer.payload.decode().splitlines()] + assert result == 0 + assert [row["text"] for row in rows] == [ + "bliss first prompt", + "bliss second prompt", + ] + assert buffer.calls > 1 + assert stdout.flush_calls == 1 + + +def test_export_broken_pipe_is_path_free( + export_home: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """A closed stdout pipe returns a clean diagnostic without traceback.""" + monkeypatch.setenv("HOME", str(export_home)) + monkeypatch.setenv("CODEX_HOME", str(export_home / ".codex")) + stdout = _BinaryStdout(_BrokenWriteBuffer()) + monkeypatch.setattr(sys, "stdout", t.cast("t.Any", stdout)) + + result = agentgrep.run_export_command(_parsed_export_args()) + + assert result == 2 + error = capsys.readouterr().err + assert "export output could not be written" in error + assert str(export_home) not in error + assert "Traceback" not in error + + +def test_export_real_broken_pipe_exits_without_shutdown_traceback( + export_home: pathlib.Path, +) -> None: + """A closed OS pipe stays handled through interpreter shutdown.""" + process = subprocess.Popen( + [ + sys.executable, + "-m", + "agentgrep", + "export", + "bliss", + "--agent", + "codex", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=_export_env(export_home), + ) + assert process.stdout is not None + assert process.stderr is not None + process.stdout.close() + error = process.stderr.read() + returncode = process.wait() + + assert returncode == 2 + assert "export output could not be written" in error + assert str(export_home) not in error + assert "Traceback" not in error + assert "Exception ignored" not in error diff --git a/tests/test_record_export.py b/tests/test_record_export.py index eb8a81563..fe42e8d96 100644 --- a/tests/test_record_export.py +++ b/tests/test_record_export.py @@ -14,7 +14,7 @@ import pytest -import agentgrep.record_export as record_export +from agentgrep import record_export from agentgrep.conversations import ConversationFidelity, ConversationUnit from agentgrep.identity import RecordIdentity from agentgrep.record_export import ( @@ -71,9 +71,9 @@ def _ndjson_rows(artifact: ExportArtifact) -> list[dict[str, object]]: return [json.loads(line) for line in artifact.text.splitlines()] -@pytest.mark.parametrize("export_format", ("ndjson", "markdown")) -@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) -@pytest.mark.parametrize("record_count", (0, 1, 3), ids=("zero", "one", "many")) +@pytest.mark.parametrize("export_format", ["ndjson", "markdown"]) +@pytest.mark.parametrize("include_bodies", [False, True], ids=("metadata", "bodies")) +@pytest.mark.parametrize("record_count", [0, 1, 3], ids=("zero", "one", "many")) def test_render_export_covers_cardinality_format_and_body_permutations( export_format: record_export.ExportFormat, include_bodies: bool, @@ -140,7 +140,7 @@ def test_ndjson_render_preserves_allowed_role_kind_and_null_metadata( assert row["model"] == model -@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +@pytest.mark.parametrize("include_bodies", [False, True], ids=("metadata", "bodies")) def test_ndjson_render_uses_exact_allowlist(include_bodies: bool) -> None: """Export rows never inherit the broader search serializer surface.""" artifact = render_export( @@ -185,8 +185,8 @@ def test_ndjson_render_preserves_repeated_content_occurrences() -> None: assert [row["text"] for row in rows] == ["repeat", "repeat"] -@pytest.mark.parametrize("export_format", ("ndjson", "markdown")) -@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +@pytest.mark.parametrize("export_format", ["ndjson", "markdown"]) +@pytest.mark.parametrize("include_bodies", [False, True], ids=("metadata", "bodies")) def test_render_export_bytes_are_stable_under_every_input_permutation( export_format: record_export.ExportFormat, include_bodies: bool, @@ -240,7 +240,7 @@ def test_ndjson_render_escapes_lone_surrogates_to_valid_utf8() -> None: assert artifact.byte_count == len(encoded) -@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +@pytest.mark.parametrize("include_bodies", [False, True], ids=("metadata", "bodies")) def test_markdown_render_rejects_emitted_lone_surrogates(include_bodies: bool) -> None: """Markdown refuses invalid UTF-8 scalars instead of altering them.""" record = _record("body\ud800" if include_bodies else "hidden\ud800", model="model\udfff") @@ -258,12 +258,12 @@ def test_markdown_render_rejects_emitted_lone_surrogates(include_bodies: bool) - @pytest.mark.parametrize( ("body", "expected_fence_length"), - ( + [ pytest.param("plain", 3, id="no-backticks"), pytest.param("before ``` after", 4, id="triple"), pytest.param("before ```` after", 5, id="quadruple"), pytest.param("before ````````````````` after", 18, id="long-run"), - ), + ], ) def test_markdown_render_uses_dynamic_backtick_fences( body: str, @@ -302,7 +302,7 @@ def test_render_thread_rejects_null_and_mixed_thread_identity() -> None: @pytest.mark.parametrize( ("records", "expected_fidelity"), - ( + [ pytest.param( ( _record( @@ -349,7 +349,7 @@ def test_render_thread_rejects_null_and_mixed_thread_identity() -> None: "unordered", id="unordered", ), - ), + ], ) def test_markdown_render_thread_discloses_every_fidelity( records: tuple[SearchRecord, ...], @@ -372,8 +372,8 @@ def test_markdown_render_thread_discloses_every_fidelity( assert f"- Thread ID: {artifact.thread_id}\n" in artifact.text -@pytest.mark.parametrize("export_format", ("ndjson", "markdown")) -@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +@pytest.mark.parametrize("export_format", ["ndjson", "markdown"]) +@pytest.mark.parametrize("include_bodies", [False, True], ids=("metadata", "bodies")) def test_render_thread_uses_export_order_under_all_input_permutations( export_format: record_export.ExportFormat, include_bodies: bool, @@ -413,8 +413,8 @@ def test_render_thread_uses_export_order_under_all_input_permutations( assert text.index("early body") < text.index("late body") -@pytest.mark.parametrize("export_format", ("ndjson", "markdown")) -@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +@pytest.mark.parametrize("export_format", ["ndjson", "markdown"]) +@pytest.mark.parametrize("include_bodies", [False, True], ids=("metadata", "bodies")) def test_render_export_never_leaks_excluded_record_fields( export_format: record_export.ExportFormat, include_bodies: bool, @@ -445,7 +445,7 @@ def test_render_export_never_leaks_excluded_record_fields( assert all(value not in artifact.text for value in excluded) -@pytest.mark.parametrize("selection", ("records", "thread")) +@pytest.mark.parametrize("selection", ["records", "thread"]) def test_render_export_prepares_each_record_identity_once( monkeypatch: pytest.MonkeyPatch, selection: record_export.ExportSelection, @@ -541,7 +541,7 @@ def _writer_artifact( ) -@pytest.mark.parametrize("private", (False, True), ids=("explicit", "private")) +@pytest.mark.parametrize("private", [False, True], ids=("explicit", "private")) def test_export_writers_reject_forged_format_without_file_side_effects( tmp_path: pathlib.Path, private: bool, @@ -553,16 +553,17 @@ def test_export_writers_reject_forged_format_without_file_side_effects( ) destination = tmp_path / ("exports" if private else "artifact.ndjson") - with pytest.raises(ExportFormatError, match="unsupported export format"): - if private: + if private: + with pytest.raises(ExportFormatError, match="unsupported export format"): write_private_export(artifact, directory=destination) - else: + else: + with pytest.raises(ExportFormatError, match="unsupported export format"): write_export(artifact, destination) assert list(tmp_path.iterdir()) == [] -@pytest.mark.parametrize("private", (False, True), ids=("explicit", "private")) +@pytest.mark.parametrize("private", [False, True], ids=("explicit", "private")) def test_export_writers_reject_forged_selection_without_file_side_effects( tmp_path: pathlib.Path, private: bool, @@ -574,10 +575,11 @@ def test_export_writers_reject_forged_selection_without_file_side_effects( ) destination = tmp_path / ("exports" if private else "artifact.ndjson") - with pytest.raises(ExportSelectionError, match="unsupported export selection"): - if private: + if private: + with pytest.raises(ExportSelectionError, match="unsupported export selection"): write_private_export(artifact, directory=destination) - else: + else: + with pytest.raises(ExportSelectionError, match="unsupported export selection"): write_export(artifact, destination) assert list(tmp_path.iterdir()) == [] @@ -637,7 +639,7 @@ def test_write_export_force_atomically_replaces_regular_file(tmp_path: pathlib.P assert stat.S_IMODE(destination.stat().st_mode) == 0o600 -@pytest.mark.parametrize("force", (False, True), ids=("no-force", "force")) +@pytest.mark.parametrize("force", [False, True], ids=("no-force", "force")) def test_write_export_rejects_destination_symlink_without_following_it( tmp_path: pathlib.Path, force: bool, From 2462c21bd1b8ac81686f76d6e572f1cc20e731d0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 09:31:56 -0500 Subject: [PATCH 06/71] agentgrep(fix[export]): Protect all stores why: Force-overwrite protection covered only sources that produced selected records, so an unmatched or unselected discovered store could be replaced by a file export. what: - Reuse one backend selection for search and file-sink inventory discovery. - Protect deduplicated paths across every agent and non-default inventory. - Keep stdout search-only and sanitize protection discovery failures. --- src/agentgrep/cli/render.py | 22 ++++++- tests/test_cli_export.py | 127 ++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) diff --git a/src/agentgrep/cli/render.py b/src/agentgrep/cli/render.py index 6af3adcb1..b053f85c6 100644 --- a/src/agentgrep/cli/render.py +++ b/src/agentgrep/cli/render.py @@ -48,6 +48,7 @@ serialize_search_record, serialize_source_handle, ) +from agentgrep.discovery import discover_sources from agentgrep.progress import ( AnswerNowInputListener, ConsoleSearchProgress, @@ -55,6 +56,7 @@ SearchProgress, noop_search_progress, ) +from agentgrep.readers import select_backends from agentgrep.records import ( AGENT_CHOICES, ColorMode, @@ -178,6 +180,7 @@ def run_export_command(args: ExportArgs) -> int: """Execute ``agentgrep export`` over the shared search engine.""" from agentgrep.record_export import ExportError, render_export, write_export + home = pathlib.Path.home() query = SearchQuery( terms=args.terms, scope=args.scope, @@ -188,13 +191,28 @@ def run_export_command(args: ExportArgs) -> int: limit=args.limit, compiled=args.compiled, ) + protected_paths: set[pathlib.Path] = set() try: + backends = select_backends() records = run_search_query( - pathlib.Path.home(), + home, query, + backends=backends, progress=noop_search_progress(), control=SearchControl(), ) + if args.output != "-": + protected_paths.update(record.path for record in records) + protected_paths.update( + source.path + for source in discover_sources( + home, + AGENT_CHOICES, + backends, + include_non_default=True, + version_detail="none", + ) + ) except OSError: _write_export_error("export source could not be read") return 2 @@ -211,7 +229,7 @@ def run_export_command(args: ExportArgs) -> int: artifact, args.output, force=args.force, - protected_paths=(record.path for record in records), + protected_paths=protected_paths, ) except ExportError as exc: _write_export_error(str(exc)) diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 565579a0e..f7782ec01 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -252,6 +252,69 @@ def test_export_protects_every_selected_record_source_path( assert "Traceback" not in completed.stderr +@pytest.mark.parametrize( + ("relative_path", "original"), + [ + ( + pathlib.Path( + ".codex/sessions/rollout-2025-04-21-unmatched-source.json", + ), + json.dumps( + { + "session": { + "id": "unmatched-source-session", + "timestamp": "2025-04-21T00:00:00Z", + }, + "items": [ + { + "id": "unmatched-source-item", + "role": "user", + "type": "message", + "content": "unrelated source prompt", + }, + ], + }, + ), + ), + ( + pathlib.Path(".claude/settings.json"), + '{"theme":"dark"}\n', + ), + ], + ids=("selected-agent", "outside-agent-non-default"), +) +def test_export_force_protects_unmatched_discovered_source( + export_home: pathlib.Path, + relative_path: pathlib.Path, + original: str, +) -> None: + """Force cannot replace unmatched inventory inside or outside selection.""" + matched_source = export_home / ".codex" / "history.jsonl" + matched_original = matched_source.read_bytes() + unmatched_source = export_home / relative_path + unmatched_source.parent.mkdir(parents=True, exist_ok=True) + _ = unmatched_source.write_text(original, encoding="utf-8") + + completed = _run_export_cli( + export_home, + "bliss", + "--agent", + "codex", + "-o", + str(unmatched_source), + "--force", + ) + + assert completed.returncode == 2 + assert matched_source.read_bytes() == matched_original + assert unmatched_source.read_text(encoding="utf-8") == original + assert completed.stdout == "" + assert "protected source" in completed.stderr + assert str(unmatched_source) not in completed.stderr + assert str(export_home) not in completed.stderr + assert "Traceback" not in completed.stderr + + def test_export_zero_matches_uses_search_exit_status(export_home: pathlib.Path) -> None: """An empty NDJSON selection emits no rows and exits with no-match status.""" completed = _run_export_cli( @@ -332,6 +395,70 @@ def fail_search(*_args: object, **_kwargs: object) -> t.NoReturn: assert "Traceback" not in error +def test_export_protection_discovery_io_failure_is_path_free( + export_home: pathlib.Path, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Inventory discovery errors cannot disclose source or destination paths.""" + monkeypatch.setenv("HOME", str(export_home)) + monkeypatch.setenv("CODEX_HOME", str(export_home / ".codex")) + private_path = export_home / ".claude" / "private-store.json" + destination = tmp_path / "records.ndjson" + + def fail_discovery(*_args: object, **_kwargs: object) -> t.NoReturn: + message = f"could not inspect {private_path}" + raise OSError(message) + + monkeypatch.setattr(cli_render, "discover_sources", fail_discovery, raising=False) + parsed = agentgrep.parse_args( + [ + "export", + "bliss", + "--agent", + "codex", + "-o", + str(destination), + ], + ) + assert isinstance(parsed, agentgrep.ExportArgs) + + result = agentgrep.run_export_command(parsed) + + assert result == 2 + assert not destination.exists() + error = capsys.readouterr().err + assert "export source could not be read" in error + assert str(private_path) not in error + assert str(destination) not in error + assert str(export_home) not in error + assert "Traceback" not in error + + +def test_export_stdout_skips_protection_discovery( + export_home: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stdout keeps the search-only discovery cost of the existing path.""" + monkeypatch.setenv("HOME", str(export_home)) + monkeypatch.setenv("CODEX_HOME", str(export_home / ".codex")) + + def unexpected_discovery(*_args: object, **_kwargs: object) -> t.NoReturn: + pytest.fail("stdout export performed protection discovery") + + monkeypatch.setattr( + cli_render, + "discover_sources", + unexpected_discovery, + raising=False, + ) + + result = agentgrep.run_export_command(_parsed_export_args()) + + assert result == 0 + + class _ShortWriteBuffer: """Binary stream that accepts only a bounded prefix per write.""" From 3135d0fbed15d46954d1cbd0f01bf650eaa00221 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 09:57:53 -0500 Subject: [PATCH 07/71] agentgrep(feat[mcp]): Add bounded export why: MCP clients need a privacy-bounded way to export selected search results without server-local writes or a second ref-matching policy. what: - Share one batched, position-aware resolver with inspect_result. - Add bounded NDJSON and Markdown export with body opt-in. - Redact refs and paths from audit and failure boundaries. --- pyproject.toml | 5 + src/agentgrep/mcp/__init__.py | 4 + src/agentgrep/mcp/middleware.py | 28 ++- src/agentgrep/mcp/models.py | 29 ++++ src/agentgrep/mcp/resolver.py | 210 +++++++++++++++++++++++ src/agentgrep/mcp/tools/__init__.py | 2 + src/agentgrep/mcp/tools/catalog_tools.py | 62 +------ src/agentgrep/mcp/tools/export_tools.py | 113 ++++++++++++ 8 files changed, 393 insertions(+), 60 deletions(-) create mode 100644 src/agentgrep/mcp/resolver.py create mode 100644 src/agentgrep/mcp/tools/export_tools.py diff --git a/pyproject.toml b/pyproject.toml index a80cd286c..e693c45bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -303,6 +303,11 @@ convention = "numpy" "src/agentgrep/_engine/search.py" = ["BLE001"] "src/agentgrep/ui/layouts/_hud_search.py" = ["PLC0414", "BLE001"] "src/agentgrep/ui/layouts/greplog.py" = ["BLE001"] +# Ref resolution and inline export are path-redacting MCP boundaries over +# arbitrary adapters and renderers. They convert every implementation failure +# into a stable, path-free client error rather than leaking backend details. +"src/agentgrep/mcp/resolver.py" = ["BLE001"] +"src/agentgrep/mcp/tools/export_tools.py" = ["BLE001"] # Catalog inspection walks whatever an adapter can parse out of a real store, # so any reader failure is data the client asked about. The MCP contract is to # return it as `error_message` with its exception type, not to fail the tool diff --git a/src/agentgrep/mcp/__init__.py b/src/agentgrep/mcp/__init__.py index 651c4869d..98d9f9b7a 100644 --- a/src/agentgrep/mcp/__init__.py +++ b/src/agentgrep/mcp/__init__.py @@ -43,6 +43,8 @@ DiagnosticModel, DiscoverySummaryRequest, DiscoverySummaryResponse, + ExportRecordsRequest, + ExportRecordsResponse, FilterSourcesRequest, FindRecordModel, FindRequestModel, @@ -98,6 +100,8 @@ "DiagnosticModel", "DiscoverySummaryRequest", "DiscoverySummaryResponse", + "ExportRecordsRequest", + "ExportRecordsResponse", "FilterSourcesRequest", "FindRecordLike", "FindRecordModel", diff --git a/src/agentgrep/mcp/middleware.py b/src/agentgrep/mcp/middleware.py index a57ca72bd..ef64e0e90 100644 --- a/src/agentgrep/mcp/middleware.py +++ b/src/agentgrep/mcp/middleware.py @@ -31,14 +31,15 @@ """Request-local FastMCP state key for caller-supplied tool argument names.""" _SENSITIVE_ARG_NAMES: frozenset[str] = frozenset( - {"terms", "pattern", "sample_text", "cursor"}, + {"terms", "pattern", "sample_text", "cursor", "ref", "refs", "source_path"}, ) """Tool argument names whose values get redacted before logging. ``terms`` and ``pattern`` can carry user secrets when an agent searches its own history for tokens; find-page ``cursor`` values encode the original pattern; ``sample_text`` is the validate-query payload and may contain -anything the caller pastes in. +anything the caller pastes in. Record refs and source paths encode or reveal +local source coordinates and receive the same treatment. """ _MAX_LOGGED_STR_LEN: int = 200 @@ -357,7 +358,9 @@ def _redact_digest(value: str) -> dict[str, t.Any]: """ return { "len": len(value), - "sha256_prefix": hashlib.sha256(value.encode("utf-8")).hexdigest()[:12], + "sha256_prefix": hashlib.sha256( + value.encode("utf-8", "surrogatepass"), + ).hexdigest()[:12], } @@ -365,9 +368,9 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: """Summarize tool arguments for audit logging. Sensitive scalars get replaced by a digest dict. Sensitive list payloads - (e.g. ``terms`` is ``list[str]``) get each element digested. Long - non-sensitive strings get truncated with a marker. Everything else passes - through as-is. + (e.g. ``terms`` is ``list[str]``) get each string element digested; invalid + non-string members expose only their type. Long non-sensitive strings get + truncated with a marker. Everything else passes through as-is. Examples -------- @@ -394,6 +397,14 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: >>> _summarize_args({"cursor": "agcur1:secret"})["cursor"]["len"] 13 + + Record refs are redacted individually, including list inputs: + + >>> refs = _summarize_args({"refs": ["agref1:first", "agref1:second"]}) + >>> [item["len"] for item in refs["refs"]] + [12, 13] + >>> "agref1" in str(refs) + False """ summary: dict[str, t.Any] = {} for key, value in args.items(): @@ -401,8 +412,11 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: summary[key] = _redact_digest(value) elif key in _SENSITIVE_ARG_NAMES and isinstance(value, list): summary[key] = [ - _redact_digest(str(item)) if isinstance(item, str) else item for item in value + _redact_digest(item) if isinstance(item, str) else {"type": type(item).__name__} + for item in value ] + elif key in _SENSITIVE_ARG_NAMES: + summary[key] = {"type": type(value).__name__} elif isinstance(value, str) and len(value) > _MAX_LOGGED_STR_LEN: summary[key] = value[:_MAX_LOGGED_STR_LEN] + "..." else: diff --git a/src/agentgrep/mcp/models.py b/src/agentgrep/mcp/models.py index ebdc6ca7c..45af29101 100644 --- a/src/agentgrep/mcp/models.py +++ b/src/agentgrep/mcp/models.py @@ -728,3 +728,32 @@ class InspectResultResponse(AgentGrepModel): sample_count: int records: list[SearchRecordModel] error_message: str | None = None + + +class ExportRecordsRequest(AgentGrepModel): + """Validated bounded inline-export request.""" + + refs: list[str] = Field(min_length=1, max_length=20) + format: t.Literal["ndjson", "markdown"] = "ndjson" + selection: t.Literal["records", "thread"] = "records" + include_bodies: bool = False + + @model_validator(mode="after") + def _require_unique_refs(self) -> ExportRecordsRequest: + """Reject duplicate physical selections before reading any source.""" + if len(set(self.refs)) != len(self.refs): + message = "refs must not contain duplicates" + raise ValueError(message) + return self + + +class ExportRecordsResponse(AgentGrepModel): + """Bounded inline export artifact returned through MCP.""" + + schema_version: str = agentgrep.SCHEMA_VERSION + format: t.Literal["ndjson", "markdown"] + selection: t.Literal["records", "thread"] + include_bodies: bool + record_count: int + byte_count: int + artifact: str diff --git a/src/agentgrep/mcp/resolver.py b/src/agentgrep/mcp/resolver.py new file mode 100644 index 000000000..cd0a85a70 --- /dev/null +++ b/src/agentgrep/mcp/resolver.py @@ -0,0 +1,210 @@ +"""Shared opaque-ref resolution for MCP drilldown and export tools.""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import pathlib +import typing as t + +from agentgrep.mcp import refs +from agentgrep.mcp._library import SearchRecordLike, SourceHandleLike, agentgrep + + +@dataclasses.dataclass(frozen=True, slots=True) +class ResolvedRecordRef: + """Records resolved from one opaque MCP ref.""" + + ref: str + kind: t.Literal["search", "find"] | None + records: tuple[SearchRecordLike, ...] = () + error_message: str | None = None + + +@dataclasses.dataclass(frozen=True, slots=True) +class _ParsedRequest: + """One parsed ref paired with its request position.""" + + index: int + ref: str + parsed: refs.ParsedRecordRef + + +def _path_key(path: pathlib.Path) -> pathlib.Path | None: + """Return a normalized lookup path or ``None`` on unsafe input.""" + try: + return path.resolve() + except OSError, RuntimeError: + return None + + +def _discover_sources(home: pathlib.Path) -> tuple[SourceHandleLike, ...] | None: + """Discover every inspectable source behind a path-free error boundary.""" + try: + backends = agentgrep.select_backends() + return tuple( + agentgrep.discover_sources( + home, + agentgrep.AGENT_CHOICES, + backends, + include_non_default=True, + version_detail="none", + ) + ) + except Exception: + return None + + +def _source_index( + sources: cabc.Iterable[SourceHandleLike], +) -> dict[tuple[str, pathlib.Path], SourceHandleLike]: + """Index discovered sources by the same adapter/path pair encoded by refs.""" + indexed: dict[tuple[str, pathlib.Path], SourceHandleLike] = {} + for source in sources: + path = _path_key(pathlib.Path(source.path)) + if path is not None: + indexed.setdefault((source.adapter_id, path), source) + return indexed + + +def _resolve_source_group( + source: SourceHandleLike, + requests: cabc.Sequence[_ParsedRequest], + results: list[ResolvedRecordRef | None], + *, + sample_size: int, +) -> None: + """Resolve every request for one source in a single record scan.""" + search_requests = [item for item in requests if item.parsed.kind == "search"] + find_requests = [item for item in requests if item.parsed.kind == "find"] + unresolved_search = {item.index: item for item in search_requests} + find_records: list[SearchRecordLike] = [] + read_failed = False + try: + for record in agentgrep.iter_source_records(source): + if len(find_records) < sample_size: + find_records.append(record) + for index, item in tuple(unresolved_search.items()): + if refs.search_record_fingerprint_matches( + record, + item.parsed.fingerprint, + ): + results[index] = ResolvedRecordRef( + ref=item.ref, + kind="search", + records=(record,), + ) + del unresolved_search[index] + if not unresolved_search and (not find_requests or len(find_records) >= sample_size): + break + except Exception: + read_failed = True + + for item in unresolved_search.values(): + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind="search", + error_message="source could not be read" if read_failed else "record not found", + ) + for item in find_requests: + if read_failed: + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind="find", + error_message="source could not be read", + ) + elif find_records: + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind="find", + records=tuple(find_records), + ) + else: + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind="find", + error_message="record not found", + ) + + +def resolve_record_refs( + ref_values: cabc.Sequence[str], + *, + sample_size: int = 1, +) -> tuple[ResolvedRecordRef, ...]: + """Resolve opaque refs with one discovery and one scan per source. + + Parameters + ---------- + ref_values + Opaque ``agref1:`` values in caller order. + sample_size + Number of records returned for a find/source ref. + + Returns + ------- + tuple[ResolvedRecordRef, ...] + One path-free resolution in the same order as ``ref_values``. + """ + if not 1 <= sample_size <= 20: + message = "sample_size must be between 1 and 20" + raise ValueError(message) + + home = pathlib.Path.home() + results: list[ResolvedRecordRef | None] = [None] * len(ref_values) + parsed_requests: list[_ParsedRequest] = [] + for index, ref in enumerate(ref_values): + try: + parsed = refs.parse_record_ref(ref, home=home) + except refs.McpTokenError as exc: + results[index] = ResolvedRecordRef( + ref=ref, + kind=None, + error_message=f"invalid ref: {exc}", + ) + else: + parsed_requests.append(_ParsedRequest(index=index, ref=ref, parsed=parsed)) + + if parsed_requests: + sources = _discover_sources(home) + if sources is None: + for item in parsed_requests: + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind=item.parsed.kind, + error_message="source discovery failed", + ) + else: + indexed_sources = _source_index(sources) + grouped: dict[ + tuple[str, pathlib.Path], + list[_ParsedRequest], + ] = {} + for item in parsed_requests: + path = _path_key(item.parsed.path) + if path is None: + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind=item.parsed.kind, + error_message="source not found", + ) + continue + key = (item.parsed.adapter_id, path) + source = indexed_sources.get(key) + if source is None: + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind=item.parsed.kind, + error_message="source not found", + ) + continue + grouped.setdefault(key, []).append(item) + for key, group in grouped.items(): + _resolve_source_group( + indexed_sources[key], + group, + results, + sample_size=sample_size, + ) + + return tuple(t.cast("ResolvedRecordRef", result) for result in results) diff --git a/src/agentgrep/mcp/tools/__init__.py b/src/agentgrep/mcp/tools/__init__.py index 4d3b4aa02..5ea80f108 100644 --- a/src/agentgrep/mcp/tools/__init__.py +++ b/src/agentgrep/mcp/tools/__init__.py @@ -16,6 +16,7 @@ def register_tools(mcp: FastMCP, *, runtime: SearchRuntime | None = None) -> Non catalog_tools, diagnostic_tools, discovery_tools, + export_tools, search_tools, ) @@ -23,3 +24,4 @@ def register_tools(mcp: FastMCP, *, runtime: SearchRuntime | None = None) -> Non discovery_tools.register(mcp) catalog_tools.register(mcp) diagnostic_tools.register(mcp) + export_tools.register(mcp) diff --git a/src/agentgrep/mcp/tools/catalog_tools.py b/src/agentgrep/mcp/tools/catalog_tools.py index ffe629b7f..ff7770395 100644 --- a/src/agentgrep/mcp/tools/catalog_tools.py +++ b/src/agentgrep/mcp/tools/catalog_tools.py @@ -9,7 +9,7 @@ from fastmcp.exceptions import ToolError from pydantic import Field -from agentgrep.mcp import refs +from agentgrep.mcp import resolver from agentgrep.mcp._library import ( READONLY_TAGS, TOOL_ANNOTATIONS, @@ -131,68 +131,24 @@ def _inspect_record_sample_sync(request: InspectSampleRequest) -> InspectSampleR def _inspect_result_sync(request: InspectResultRequest) -> InspectResultResponse: """Resolve an opaque result ref and return source records.""" - home = pathlib.Path.home() + resolved = resolver.resolve_record_refs( + (request.ref,), + sample_size=request.sample_size, + )[0] try: - parsed = refs.parse_record_ref(request.ref, home=home) - except refs.McpTokenError as exc: + records = [SearchRecordModel.from_record(record) for record in resolved.records] + except Exception: return InspectResultResponse( ref=request.ref, sample_count=0, records=[], - error_message=f"invalid ref: {exc}", - ) - backends = agentgrep.select_backends() - sources = agentgrep.discover_sources( - home, - agentgrep.AGENT_CHOICES, - backends, - include_non_default=True, - version_detail="none", - ) - target = next( - ( - source - for source in sources - if source.adapter_id == parsed.adapter_id - and pathlib.Path(source.path).resolve() == parsed.path.resolve() - ), - None, - ) - if target is None: - return InspectResultResponse( - ref=request.ref, - sample_count=0, - records=[], - error_message="source not found", - ) - try: - records: list[SearchRecordModel] = [] - for record in agentgrep.iter_source_records(target): - if parsed.kind == "search" and ( - not refs.search_record_fingerprint_matches(record, parsed.fingerprint) - ): - continue - records.append(SearchRecordModel.from_record(record)) - if parsed.kind == "search" or len(records) >= request.sample_size: - break - except Exception as exc: - return InspectResultResponse( - ref=request.ref, - sample_count=0, - records=[], - error_message=f"{type(exc).__name__}: {exc}", - ) - if not records: - return InspectResultResponse( - ref=request.ref, - sample_count=0, - records=[], - error_message="record not found", + error_message="source record could not be represented", ) return InspectResultResponse( ref=request.ref, sample_count=len(records), records=records, + error_message=resolved.error_message, ) diff --git a/src/agentgrep/mcp/tools/export_tools.py b/src/agentgrep/mcp/tools/export_tools.py new file mode 100644 index 000000000..7f16803d4 --- /dev/null +++ b/src/agentgrep/mcp/tools/export_tools.py @@ -0,0 +1,113 @@ +"""Bounded inline record export for MCP clients.""" + +from __future__ import annotations + +import asyncio +import typing as t + +from fastmcp.exceptions import ToolError +from pydantic import Field, ValidationError + +from agentgrep.mcp._library import READONLY_TAGS, TOOL_ANNOTATIONS +from agentgrep.mcp.models import ExportRecordsRequest, ExportRecordsResponse +from agentgrep.mcp.resolver import resolve_record_refs +from agentgrep.record_export import ExportError, render_export + +if t.TYPE_CHECKING: + from fastmcp import FastMCP + + from agentgrep.records import SearchRecord + + +MAX_INLINE_EXPORT_BYTES = 400 * 1024 +"""Maximum UTF-8 artifact size returned by ``export_records``.""" + + +def _export_records_sync(request: ExportRecordsRequest) -> ExportRecordsResponse: + """Resolve selected records and render one bounded inline artifact.""" + resolved = resolve_record_refs(request.refs) + records: list[SearchRecord] = [] + for index, item in enumerate(resolved, start=1): + if item.error_message is not None: + message = f"ref {index} could not be resolved: {item.error_message}" + raise ToolError(message) + if item.kind != "search" or len(item.records) != 1: + message = f"ref {index} does not identify an exportable record" + raise ToolError(message) + records.append(t.cast("SearchRecord", item.records[0])) + try: + artifact = render_export( + records, + format=request.format, + selection=request.selection, + include_bodies=request.include_bodies, + ) + except ExportError as exc: + raise ToolError(str(exc)) from exc + except Exception: + message = "export artifact could not be rendered" + raise ToolError(message) from None + if artifact.byte_count > MAX_INLINE_EXPORT_BYTES: + message = "export artifact exceeds the 400 KiB inline limit" + raise ToolError(message) + return ExportRecordsResponse( + format=artifact.format, + selection=artifact.selection, + include_bodies=request.include_bodies, + record_count=artifact.record_count, + byte_count=artifact.byte_count, + artifact=artifact.text, + ) + + +def register(mcp: FastMCP) -> None: + """Register the bounded record-export tool.""" + + @mcp.tool( + name="export_records", + tags=READONLY_TAGS | {"export"}, + annotations=TOOL_ANNOTATIONS, + description="Render selected search-result refs as bounded NDJSON or Markdown.", + ) + async def export_records_tool( + # FastMCP logs pre-handler validation inputs. Publish the exact wire + # schema here, then validate inside the redacted tool boundary so an + # oversized ref collection cannot put opaque source coordinates in logs. + refs: t.Annotated[ + t.Any, + Field( + description="One to 20 opaque refs returned by search.", + json_schema_extra={ + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 20, + }, + ), + ], + format: t.Annotated[ # noqa: A002 - required MCP argument name. + t.Literal["ndjson", "markdown"], + Field(description="Inline artifact format."), + ] = "ndjson", + selection: t.Annotated[ + t.Literal["records", "thread"], + Field(description="Export flat records or one observed thread."), + ] = "records", + include_bodies: t.Annotated[ + bool, + Field(description="Include prompt/history text in the artifact."), + ] = False, + ) -> ExportRecordsResponse: + try: + request = ExportRecordsRequest( + refs=refs, + format=format, + selection=selection, + include_bodies=include_bodies, + ) + except ValidationError: + message = "invalid export request" + raise ToolError(message) from None + return await asyncio.to_thread(_export_records_sync, request) + + _ = export_records_tool From 2c744ae8b4a7b1d2fa2a16a0e579b5284ef7f265 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:16:26 -0500 Subject: [PATCH 08/71] agentgrep(fix[mcp]): Keep export envelope why: Typed FastMCP responses duplicated large artifacts into text and structured content, so the response limiter truncated valid 400 KiB exports. what: - Return one TextContent artifact with schema-validated metadata. - Check the complete serialized envelope before middleware truncation. - Cover exact and escape-expanded limits through a real MCP client. --- src/agentgrep/mcp/_library.py | 4 ++++ src/agentgrep/mcp/models.py | 3 +-- src/agentgrep/mcp/server.py | 7 +----- src/agentgrep/mcp/tools/export_tools.py | 30 ++++++++++++++++++++----- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/agentgrep/mcp/_library.py b/src/agentgrep/mcp/_library.py index 1daaf90c2..974100149 100644 --- a/src/agentgrep/mcp/_library.py +++ b/src/agentgrep/mcp/_library.py @@ -62,6 +62,10 @@ SearchEffortName = t.Literal["prompt", "targeted", "exhaustive"] SERVER_VERSION = "0.1.0" +#: Byte ceiling shared by the response middleware and tools that must avoid +#: producing a response which the middleware can only truncate. A typical +#: record is about 1 KiB, so 512 KiB retains a useful bounded result slice. +DEFAULT_RESPONSE_LIMIT_BYTES = 512 * 1024 KNOWN_ADAPTERS: tuple[str, ...] = ( "codex.history_json.v1", "codex.history_jsonl.v1", diff --git a/src/agentgrep/mcp/models.py b/src/agentgrep/mcp/models.py index 45af29101..d2be6ff65 100644 --- a/src/agentgrep/mcp/models.py +++ b/src/agentgrep/mcp/models.py @@ -748,7 +748,7 @@ def _require_unique_refs(self) -> ExportRecordsRequest: class ExportRecordsResponse(AgentGrepModel): - """Bounded inline export artifact returned through MCP.""" + """Structured metadata for a bounded inline MCP export artifact.""" schema_version: str = agentgrep.SCHEMA_VERSION format: t.Literal["ndjson", "markdown"] @@ -756,4 +756,3 @@ class ExportRecordsResponse(AgentGrepModel): include_bodies: bool record_count: int byte_count: int - artifact: str diff --git a/src/agentgrep/mcp/server.py b/src/agentgrep/mcp/server.py index 93607d372..7e9d26a30 100644 --- a/src/agentgrep/mcp/server.py +++ b/src/agentgrep/mcp/server.py @@ -7,7 +7,7 @@ from fastmcp.server.middleware.timing import TimingMiddleware from agentgrep._engine.runtime import SearchRuntime -from agentgrep.mcp._library import SERVER_VERSION +from agentgrep.mcp._library import DEFAULT_RESPONSE_LIMIT_BYTES, SERVER_VERSION from agentgrep.mcp.instructions import _build_instructions from agentgrep.mcp.middleware import ( AgentgrepArgumentPresenceMiddleware, @@ -20,11 +20,6 @@ from agentgrep.mcp.resources import register_resources from agentgrep.mcp.tools import register_tools -#: Byte ceiling for response truncation. Sized to fit a generous slice of -#: prompt/history records (a typical record is ~1 KB; 512 KB allows a few -#: hundred records before truncation fires). -DEFAULT_RESPONSE_LIMIT_BYTES = 512 * 1024 - def build_mcp_server() -> FastMCP: """Build and return the FastMCP server instance.""" diff --git a/src/agentgrep/mcp/tools/export_tools.py b/src/agentgrep/mcp/tools/export_tools.py index 7f16803d4..17a728167 100644 --- a/src/agentgrep/mcp/tools/export_tools.py +++ b/src/agentgrep/mcp/tools/export_tools.py @@ -5,10 +5,17 @@ import asyncio import typing as t +import pydantic_core from fastmcp.exceptions import ToolError +from fastmcp.tools.base import ToolResult +from mcp.types import TextContent from pydantic import Field, ValidationError -from agentgrep.mcp._library import READONLY_TAGS, TOOL_ANNOTATIONS +from agentgrep.mcp._library import ( + DEFAULT_RESPONSE_LIMIT_BYTES, + READONLY_TAGS, + TOOL_ANNOTATIONS, +) from agentgrep.mcp.models import ExportRecordsRequest, ExportRecordsResponse from agentgrep.mcp.resolver import resolve_record_refs from agentgrep.record_export import ExportError, render_export @@ -23,7 +30,7 @@ """Maximum UTF-8 artifact size returned by ``export_records``.""" -def _export_records_sync(request: ExportRecordsRequest) -> ExportRecordsResponse: +def _export_records_sync(request: ExportRecordsRequest) -> ToolResult: """Resolve selected records and render one bounded inline artifact.""" resolved = resolve_record_refs(request.refs) records: list[SearchRecord] = [] @@ -50,14 +57,21 @@ def _export_records_sync(request: ExportRecordsRequest) -> ExportRecordsResponse if artifact.byte_count > MAX_INLINE_EXPORT_BYTES: message = "export artifact exceeds the 400 KiB inline limit" raise ToolError(message) - return ExportRecordsResponse( + response = ExportRecordsResponse( format=artifact.format, selection=artifact.selection, include_bodies=request.include_bodies, record_count=artifact.record_count, byte_count=artifact.byte_count, - artifact=artifact.text, ) + result = ToolResult( + content=[TextContent(type="text", text=artifact.text)], + structured_content=response.model_dump(mode="json"), + ) + if len(pydantic_core.to_json(result, fallback=str)) > DEFAULT_RESPONSE_LIMIT_BYTES: + message = "export artifact exceeds the MCP response limit" + raise ToolError(message) + return result def register(mcp: FastMCP) -> None: @@ -67,7 +81,11 @@ def register(mcp: FastMCP) -> None: name="export_records", tags=READONLY_TAGS | {"export"}, annotations=TOOL_ANNOTATIONS, - description="Render selected search-result refs as bounded NDJSON or Markdown.", + output_schema=ExportRecordsResponse.model_json_schema(), + description=( + "Return selected refs as one NDJSON or Markdown TextContent artifact " + "with structured export metadata." + ), ) async def export_records_tool( # FastMCP logs pre-handler validation inputs. Publish the exact wire @@ -97,7 +115,7 @@ async def export_records_tool( bool, Field(description="Include prompt/history text in the artifact."), ] = False, - ) -> ExportRecordsResponse: + ) -> ToolResult: try: request = ExportRecordsRequest( refs=refs, From abf30f8528ee173e39fd32ca51e6c4da3530d6fd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:20:23 -0500 Subject: [PATCH 09/71] agentgrep(fix[mcp]): Reject aliased refs why: Different token encodings and historical refs can resolve to the same physical turn even when their raw strings differ. what: - Attach a path-hiding source key and scan ordinal to resolved records. - Reject repeated physical selections after resolution. - Preserve distinct positioned occurrences with explicit regression tests. --- src/agentgrep/mcp/resolver.py | 24 +++++++++++++++++++++++- src/agentgrep/mcp/tools/export_tools.py | 10 +++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/agentgrep/mcp/resolver.py b/src/agentgrep/mcp/resolver.py index cd0a85a70..20d1a06d6 100644 --- a/src/agentgrep/mcp/resolver.py +++ b/src/agentgrep/mcp/resolver.py @@ -4,6 +4,7 @@ import collections.abc as cabc import dataclasses +import hashlib import pathlib import typing as t @@ -11,6 +12,14 @@ from agentgrep.mcp._library import SearchRecordLike, SourceHandleLike, agentgrep +@dataclasses.dataclass(frozen=True, slots=True) +class PhysicalRecordSelection: + """Privacy-safe physical source key and scan ordinal for one record.""" + + source_key: str + record_ordinal: int + + @dataclasses.dataclass(frozen=True, slots=True) class ResolvedRecordRef: """Records resolved from one opaque MCP ref.""" @@ -18,6 +27,7 @@ class ResolvedRecordRef: ref: str kind: t.Literal["search", "find"] | None records: tuple[SearchRecordLike, ...] = () + physical_selection: PhysicalRecordSelection | None = None error_message: str | None = None @@ -67,11 +77,18 @@ def _source_index( return indexed +def _physical_source_key(adapter_id: str, path: pathlib.Path) -> str: + """Return a path-hiding key for one adapter and resolved source path.""" + raw = f"{adapter_id}\0{path}".encode("utf-8", "surrogatepass") + return hashlib.sha256(raw).hexdigest() + + def _resolve_source_group( source: SourceHandleLike, requests: cabc.Sequence[_ParsedRequest], results: list[ResolvedRecordRef | None], *, + source_key: str, sample_size: int, ) -> None: """Resolve every request for one source in a single record scan.""" @@ -81,7 +98,7 @@ def _resolve_source_group( find_records: list[SearchRecordLike] = [] read_failed = False try: - for record in agentgrep.iter_source_records(source): + for record_ordinal, record in enumerate(agentgrep.iter_source_records(source)): if len(find_records) < sample_size: find_records.append(record) for index, item in tuple(unresolved_search.items()): @@ -93,6 +110,10 @@ def _resolve_source_group( ref=item.ref, kind="search", records=(record,), + physical_selection=PhysicalRecordSelection( + source_key=source_key, + record_ordinal=record_ordinal, + ), ) del unresolved_search[index] if not unresolved_search and (not find_requests or len(find_records) >= sample_size): @@ -204,6 +225,7 @@ def resolve_record_refs( indexed_sources[key], group, results, + source_key=_physical_source_key(*key), sample_size=sample_size, ) diff --git a/src/agentgrep/mcp/tools/export_tools.py b/src/agentgrep/mcp/tools/export_tools.py index 17a728167..2c3a87e01 100644 --- a/src/agentgrep/mcp/tools/export_tools.py +++ b/src/agentgrep/mcp/tools/export_tools.py @@ -17,7 +17,7 @@ TOOL_ANNOTATIONS, ) from agentgrep.mcp.models import ExportRecordsRequest, ExportRecordsResponse -from agentgrep.mcp.resolver import resolve_record_refs +from agentgrep.mcp.resolver import PhysicalRecordSelection, resolve_record_refs from agentgrep.record_export import ExportError, render_export if t.TYPE_CHECKING: @@ -34,6 +34,7 @@ def _export_records_sync(request: ExportRecordsRequest) -> ToolResult: """Resolve selected records and render one bounded inline artifact.""" resolved = resolve_record_refs(request.refs) records: list[SearchRecord] = [] + physical_selections: set[PhysicalRecordSelection] = set() for index, item in enumerate(resolved, start=1): if item.error_message is not None: message = f"ref {index} could not be resolved: {item.error_message}" @@ -41,6 +42,13 @@ def _export_records_sync(request: ExportRecordsRequest) -> ToolResult: if item.kind != "search" or len(item.records) != 1: message = f"ref {index} does not identify an exportable record" raise ToolError(message) + if item.physical_selection is None: + message = f"ref {index} has no physical record selection" + raise ToolError(message) + if item.physical_selection in physical_selections: + message = "refs resolve to a duplicate physical record" + raise ToolError(message) + physical_selections.add(item.physical_selection) records.append(t.cast("SearchRecord", item.records[0])) try: artifact = render_export( From b5acc4152fb00cf655ea5ad4a58cdab0185973ed Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:24:06 -0500 Subject: [PATCH 10/71] agentgrep(fix[mcp]): Fail closed on discovery why: A resolver-wide discovery failure is an operation error, not evidence that one requested source or record is absent. what: - Raise one typed path-free resolver failure for discovery errors. - Map the failure to tool errors in inspect and export. - Preserve successful unresolved responses for ordinary missing selections. --- src/agentgrep/mcp/resolver.py | 71 ++++++++++++------------ src/agentgrep/mcp/tools/catalog_tools.py | 11 ++-- src/agentgrep/mcp/tools/export_tools.py | 11 +++- 3 files changed, 50 insertions(+), 43 deletions(-) diff --git a/src/agentgrep/mcp/resolver.py b/src/agentgrep/mcp/resolver.py index 20d1a06d6..007081bc3 100644 --- a/src/agentgrep/mcp/resolver.py +++ b/src/agentgrep/mcp/resolver.py @@ -12,6 +12,10 @@ from agentgrep.mcp._library import SearchRecordLike, SourceHandleLike, agentgrep +class RecordRefResolverError(RuntimeError): + """A resolver-wide failure that cannot be assigned to one ref.""" + + @dataclasses.dataclass(frozen=True, slots=True) class PhysicalRecordSelection: """Privacy-safe physical source key and scan ordinal for one record.""" @@ -48,7 +52,7 @@ def _path_key(path: pathlib.Path) -> pathlib.Path | None: return None -def _discover_sources(home: pathlib.Path) -> tuple[SourceHandleLike, ...] | None: +def _discover_sources(home: pathlib.Path) -> tuple[SourceHandleLike, ...]: """Discover every inspectable source behind a path-free error boundary.""" try: backends = agentgrep.select_backends() @@ -62,7 +66,8 @@ def _discover_sources(home: pathlib.Path) -> tuple[SourceHandleLike, ...] | None ) ) except Exception: - return None + message = "source discovery failed" + raise RecordRefResolverError(message) from None def _source_index( @@ -188,45 +193,37 @@ def resolve_record_refs( if parsed_requests: sources = _discover_sources(home) - if sources is None: - for item in parsed_requests: + indexed_sources = _source_index(sources) + grouped: dict[ + tuple[str, pathlib.Path], + list[_ParsedRequest], + ] = {} + for item in parsed_requests: + path = _path_key(item.parsed.path) + if path is None: results[item.index] = ResolvedRecordRef( ref=item.ref, kind=item.parsed.kind, - error_message="source discovery failed", + error_message="source not found", ) - else: - indexed_sources = _source_index(sources) - grouped: dict[ - tuple[str, pathlib.Path], - list[_ParsedRequest], - ] = {} - for item in parsed_requests: - path = _path_key(item.parsed.path) - if path is None: - results[item.index] = ResolvedRecordRef( - ref=item.ref, - kind=item.parsed.kind, - error_message="source not found", - ) - continue - key = (item.parsed.adapter_id, path) - source = indexed_sources.get(key) - if source is None: - results[item.index] = ResolvedRecordRef( - ref=item.ref, - kind=item.parsed.kind, - error_message="source not found", - ) - continue - grouped.setdefault(key, []).append(item) - for key, group in grouped.items(): - _resolve_source_group( - indexed_sources[key], - group, - results, - source_key=_physical_source_key(*key), - sample_size=sample_size, + continue + key = (item.parsed.adapter_id, path) + source = indexed_sources.get(key) + if source is None: + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind=item.parsed.kind, + error_message="source not found", ) + continue + grouped.setdefault(key, []).append(item) + for key, group in grouped.items(): + _resolve_source_group( + indexed_sources[key], + group, + results, + source_key=_physical_source_key(*key), + sample_size=sample_size, + ) return tuple(t.cast("ResolvedRecordRef", result) for result in results) diff --git a/src/agentgrep/mcp/tools/catalog_tools.py b/src/agentgrep/mcp/tools/catalog_tools.py index ff7770395..c34d47c4c 100644 --- a/src/agentgrep/mcp/tools/catalog_tools.py +++ b/src/agentgrep/mcp/tools/catalog_tools.py @@ -131,10 +131,13 @@ def _inspect_record_sample_sync(request: InspectSampleRequest) -> InspectSampleR def _inspect_result_sync(request: InspectResultRequest) -> InspectResultResponse: """Resolve an opaque result ref and return source records.""" - resolved = resolver.resolve_record_refs( - (request.ref,), - sample_size=request.sample_size, - )[0] + try: + resolved = resolver.resolve_record_refs( + (request.ref,), + sample_size=request.sample_size, + )[0] + except resolver.RecordRefResolverError as exc: + raise ToolError(str(exc)) from None try: records = [SearchRecordModel.from_record(record) for record in resolved.records] except Exception: diff --git a/src/agentgrep/mcp/tools/export_tools.py b/src/agentgrep/mcp/tools/export_tools.py index 2c3a87e01..7a48d3b72 100644 --- a/src/agentgrep/mcp/tools/export_tools.py +++ b/src/agentgrep/mcp/tools/export_tools.py @@ -17,7 +17,11 @@ TOOL_ANNOTATIONS, ) from agentgrep.mcp.models import ExportRecordsRequest, ExportRecordsResponse -from agentgrep.mcp.resolver import PhysicalRecordSelection, resolve_record_refs +from agentgrep.mcp.resolver import ( + PhysicalRecordSelection, + RecordRefResolverError, + resolve_record_refs, +) from agentgrep.record_export import ExportError, render_export if t.TYPE_CHECKING: @@ -32,7 +36,10 @@ def _export_records_sync(request: ExportRecordsRequest) -> ToolResult: """Resolve selected records and render one bounded inline artifact.""" - resolved = resolve_record_refs(request.refs) + try: + resolved = resolve_record_refs(request.refs) + except RecordRefResolverError as exc: + raise ToolError(str(exc)) from None records: list[SearchRecord] = [] physical_selections: set[PhysicalRecordSelection] = set() for index, item in enumerate(resolved, start=1): From fa74adf2b54d6a74b31d17e5a19c8e0cfad45122 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:33:42 -0500 Subject: [PATCH 11/71] agentgrep(docs[mcp]): Clarify ref dedupe why: The request validator compares raw ref strings, while semantic aliases are rejected only after resolver matching. what: - Describe the validator's pre-discovery string-equality scope. --- src/agentgrep/mcp/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/agentgrep/mcp/models.py b/src/agentgrep/mcp/models.py index d2be6ff65..d3e1f9f66 100644 --- a/src/agentgrep/mcp/models.py +++ b/src/agentgrep/mcp/models.py @@ -740,7 +740,7 @@ class ExportRecordsRequest(AgentGrepModel): @model_validator(mode="after") def _require_unique_refs(self) -> ExportRecordsRequest: - """Reject duplicate physical selections before reading any source.""" + """Reject identical raw ref strings before source discovery.""" if len(set(self.refs)) != len(self.refs): message = "refs must not contain duplicates" raise ValueError(message) From d9ed2302afde190e8d6b205ff02f0c70bb1bcb23 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:16:27 -0500 Subject: [PATCH 12/71] agentgrep(feat[export]): Add TUI workflow why: Headless surfaces already support export. Selected records and observed threads also need a bounded, non-blocking path from the interactive explorer. what: - Add argument-aware record and observed-thread slash commands. - Snapshot results in chunks and render/write in a gated worker. - Cover sink, identity, safety, race, teardown, and watchdog cases. --- pyproject.toml | 3 + src/agentgrep/ui/commands.py | 33 ++ src/agentgrep/ui/layouts/hud.py | 366 ++++++++++++++- tests/test_record_export.py | 26 +- tests/test_ui_export.py | 760 ++++++++++++++++++++++++++++++++ 5 files changed, 1179 insertions(+), 9 deletions(-) create mode 100644 tests/test_ui_export.py diff --git a/pyproject.toml b/pyproject.toml index e693c45bc..07f2b23de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -303,6 +303,9 @@ convention = "numpy" "src/agentgrep/_engine/search.py" = ["BLE001"] "src/agentgrep/ui/layouts/_hud_search.py" = ["PLC0414", "BLE001"] "src/agentgrep/ui/layouts/greplog.py" = ["BLE001"] +# The export worker redacts arbitrary renderer and filesystem failures at the +# thread boundary so backend details and destination paths cannot reach the UI. +"src/agentgrep/ui/layouts/hud.py" = ["BLE001"] # Ref resolution and inline export are path-redacting MCP boundaries over # arbitrary adapters and renderers. They convert every implementation failure # into a stable, path-free client error rather than leaking backend details. diff --git a/src/agentgrep/ui/commands.py b/src/agentgrep/ui/commands.py index da81cab20..f229c59f8 100644 --- a/src/agentgrep/ui/commands.py +++ b/src/agentgrep/ui/commands.py @@ -25,6 +25,7 @@ "SlashCommand", "command_matches", "command_menu_label", + "export_commands", "parse_command", "resolve_command", "zoom_commands", @@ -153,6 +154,16 @@ def _run_minimize(app: t.Any, args: str) -> bool: return bool(app.handle_minimize_command()) +def _run_export(app: t.Any, args: str) -> bool: + """Export the selected record to a private or explicit destination.""" + return bool(app.request_export(args, selection="records")) + + +def _run_export_thread(app: t.Any, args: str) -> bool: + """Export the selected record's observed thread.""" + return bool(app.request_export(args, selection="thread")) + + def _command_label(cmd: SlashCommand) -> str: """Render ``/name (/alias1, /alias2)`` for menus and help (argparse-style).""" label = f"/{command_menu_label(cmd)}" @@ -225,6 +236,28 @@ def zoom_commands(argument_hint: str) -> tuple[SlashCommand, SlashCommand]: ) +def export_commands() -> tuple[SlashCommand, SlashCommand]: + """Return the export commands supported only by the HUD layout.""" + return ( + SlashCommand( + "export", + (), + "Export selected record", + _run_export, + "[PATH]", + accepts_args=True, + ), + SlashCommand( + "export-thread", + (), + "Export observed thread", + _run_export_thread, + "[PATH]", + accepts_args=True, + ), + ) + + def resolve_command( token: str, slash_commands: tuple[SlashCommand, ...] = SLASH_COMMANDS, diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index 4361b6978..fa0e3157d 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -9,7 +9,12 @@ from __future__ import annotations +import asyncio import collections +import dataclasses +import functools +import pathlib +import threading import typing as t from collections import abc as cabc @@ -28,7 +33,7 @@ from agentgrep.progress import ProgressSnapshot from agentgrep.query import default_registry from agentgrep.records import SearchRecord -from agentgrep.ui import _history, _runtime, theme as ui_theme +from agentgrep.ui import _history, _runtime, commands, theme as ui_theme from agentgrep.ui._context import UiContext from agentgrep.ui._result_status import depth_offer_typed_directive from agentgrep.ui.completion import QuerySuggester @@ -71,10 +76,68 @@ from agentgrep.ui.workflows import Workflow +type _ExportSelection = t.Literal["records", "thread"] + + +@dataclasses.dataclass(frozen=True, slots=True) +class _ExportSnapshot: + """Pump-captured values owned by one export worker.""" + + selected: SearchRecord + records: list[SearchRecord] + destination: str | None + selection: _ExportSelection + canceled: threading.Event + + +@dataclasses.dataclass(frozen=True, slots=True) +class _ExportCompleted: + """Path-safe worker outcome delivered to the pump.""" + + filename: str | None + format: str + selection: _ExportSelection + record_count: int + error: str | None + + +class _ExportSnapshotChangedError(Exception): + """Stop a chunked snapshot when its displayed result set changes.""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class _ExportRecordView(cabc.Sequence[SearchRecord]): + """A zero-copy sequence capped at the result count seen on acceptance.""" + + records: list[SearchRecord] + count: int + + def __len__(self) -> int: + return self.count + + @t.overload + def __getitem__(self, index: int) -> SearchRecord: ... + + @t.overload + def __getitem__(self, index: slice) -> list[SearchRecord]: ... + + def __getitem__(self, index: int | slice) -> SearchRecord | list[SearchRecord]: + if isinstance(index, slice): + start, stop, step = index.indices(self.count) + return self.records[start:stop:step] + normalized = index + self.count if index < 0 else index + if not 0 <= normalized < self.count: + raise IndexError(index) + return self.records[normalized] + + class HudLayout(_HudSearchBase): """Search box, streaming results list, detail pane, and status chrome.""" ZOOM_ARGUMENT_HINT: t.ClassVar[str] = "[results|detail]" + EXTRA_SLASH_COMMANDS: t.ClassVar[tuple[commands.SlashCommand, ...]] = ( + commands.export_commands() + ) # ``priority=True`` on the directional ``ctrl+hjkl`` bindings pushes # them into Textual's priority dispatch lane so they win over any @@ -161,6 +224,13 @@ def __init__(self, ctx: UiContext, workflow: Workflow) -> None: self._history_path = _history.history_path(self.home) self._history = list(ctx.history) self._last_recorded_text = self._history[0].text if self._history else "" + # Export is a non-supersedable durable action. The pump prepares one + # point-in-time result snapshot in bounded chunks, then transfers sole + # ownership to a thread worker. A second request remains blocked until + # the first worker reports a terminal outcome. + self._export_pending: bool = False + self._export_generation: int = 0 + self._export_cancel_event: threading.Event | None = None self._results: SearchResultsList | None = None # The detail pane is un-Grouped into two stacked, individually # selectable ``Static``s: the metadata header and the body. A single @@ -529,6 +599,16 @@ def on_mount(self) -> None: self._search_input.focus() self._update_pane_focus() + @_runtime.pump_only + def on_unmount(self) -> None: + """Invalidate export callbacks and cancel work during screen teardown.""" + self._export_generation += 1 + self._export_pending = False + if self._export_cancel_event is not None: + self._export_cancel_event.set() + self._export_cancel_event = None + self.workers.cancel_group(self, "export") + def _set_empty_state(self, *, empty: bool) -> None: """Toggle the pre-search bare-canvas state on ``#body``. @@ -977,6 +1057,290 @@ def action_focus_pane_down(self) -> None: elif focused_id == "results" and self._stacked: self._focus_detail() + @_runtime.pump_only + def request_export(self, destination: str, *, selection: _ExportSelection) -> bool: + """Accept one selected-record or observed-thread export request. + + Only bounded state capture happens synchronously. Thread exports copy + the displayed result set through :func:`stream_apply`; identity, + rendering, path handling, and durable output stay in the export worker. + """ + if self._export_pending: + self.notify( + "Export already in progress", + title="Export busy", + severity="warning", + ) + return False + selected = self._selected_export_record() + if selected is None: + self.notify( + "Select a record before exporting", + title="Export failed", + severity="error", + ) + return False + + self._export_generation += 1 + generation = self._export_generation + canceled = threading.Event() + self._export_cancel_event = canceled + self._export_pending = True + active_records = self.filtered_records + active_count = len(active_records) + chrome_generation = self._chrome_generation + self.call_later( + self._snapshot_and_start_export, + generation, + selected, + selection, + destination or None, + active_records, + active_count, + chrome_generation, + canceled, + ) + return True + + def _selected_export_record(self) -> SearchRecord | None: + """Return the selected result without scanning the full result set.""" + highlighted = None + if self._results is not None: + highlighted = t.cast("int | None", getattr(self._results, "highlighted", None)) + if highlighted is not None and 0 <= highlighted < len(self.filtered_records): + return self.filtered_records[highlighted] + if self._current_detail_record is not None: + return self._current_detail_record + return self.filtered_records[0] if self.filtered_records else None + + def _export_snapshot_is_live( + self, + generation: int, + active_records: list[SearchRecord], + chrome_generation: int, + canceled: threading.Event, + ) -> bool: + """Return whether a pump-side snapshot still describes one result view.""" + return ( + generation == self._export_generation + and self._export_pending + and not canceled.is_set() + and self.is_mounted + and active_records is self.filtered_records + and chrome_generation == self._chrome_generation + ) + + @_runtime.pump_only + async def _snapshot_and_start_export( + self, + generation: int, + selected: SearchRecord, + selection: _ExportSelection, + destination: str | None, + active_records: list[SearchRecord], + active_count: int, + chrome_generation: int, + canceled: threading.Event, + ) -> None: + """Copy a coherent result view in bounded chunks, then start the worker.""" + if not self._export_snapshot_is_live( + generation, + active_records, + chrome_generation, + canceled, + ): + self._abort_export_snapshot(generation, canceled) + return + + records: list[SearchRecord] = [] + if selection == "records": + records.append(selected) + else: + + async def yield_and_gate() -> None: + await asyncio.sleep(0) + if not self._export_snapshot_is_live( + generation, + active_records, + chrome_generation, + canceled, + ): + raise _ExportSnapshotChangedError + + try: + await _runtime.stream_apply( + _ExportRecordView(active_records, active_count), + records.extend, + chunk_size=self._APPLY_CHUNK_SIZE, + yield_between=yield_and_gate, + ) + except _ExportSnapshotChangedError: + self._abort_export_snapshot(generation, canceled) + return + + if not self._export_snapshot_is_live( + generation, + active_records, + chrome_generation, + canceled, + ): + self._abort_export_snapshot(generation, canceled) + return + + snapshot = _ExportSnapshot( + selected=selected, + records=records, + destination=destination, + selection=selection, + canceled=canceled, + ) + emit = _runtime.make_gated_emitter( + self.app.call_from_thread, + self._apply_export_completed, + generation, + ) + streaming = t.cast("StreamingAppLike", t.cast("object", self)) + streaming.run_worker( + functools.partial(self._run_export_in_thread, snapshot, emit), + name="export", + group="export", + description="render and write export", + thread=True, + exclusive=True, + ) + + def _abort_export_snapshot( + self, + generation: int, + canceled: threading.Event, + ) -> None: + """Release a pre-worker export whose displayed results changed.""" + canceled.set() + if generation != self._export_generation: + return + self._export_pending = False + if self._export_cancel_event is canceled: + self._export_cancel_event = None + if self.is_mounted: + self.notify( + "Export canceled because results changed", + title="Export canceled", + severity="warning", + ) + + @_runtime.offload + def _run_export_in_thread( + self, + snapshot: _ExportSnapshot, + emit: cabc.Callable[[object], None], + ) -> None: + """Resolve, render, and durably write one pump-owned export snapshot.""" + from agentgrep.record_export import ( + ExportError, + render_export, + write_export, + write_private_export, + ) + + if snapshot.canceled.is_set(): + return + try: + records = self._select_export_records(snapshot) + artifact = render_export( + records, + format="markdown", + include_bodies=True, + selection=snapshot.selection, + ) + if snapshot.canceled.is_set(): + return + if snapshot.destination is None: + written = write_private_export(artifact) + else: + destination = pathlib.Path(snapshot.destination).expanduser() + written = write_export( + artifact, + destination, + protected_paths=(record.path for record in snapshot.records), + ) + if snapshot.canceled.is_set(): + return + completed = _ExportCompleted( + filename=self._safe_export_filename(written), + format=artifact.format, + selection=snapshot.selection, + record_count=artifact.record_count, + error=None, + ) + except ExportError as exc: + completed = _ExportCompleted( + filename=None, + format="markdown", + selection=snapshot.selection, + record_count=0, + error=str(exc), + ) + except Exception: + completed = _ExportCompleted( + filename=None, + format="markdown", + selection=snapshot.selection, + record_count=0, + error="export could not be completed", + ) + if not snapshot.canceled.is_set(): + emit(completed) + + @staticmethod + def _select_export_records(snapshot: _ExportSnapshot) -> cabc.Iterable[SearchRecord]: + """Return the exact selected record or matching canonical thread.""" + if snapshot.selection == "records": + return (snapshot.selected,) + from agentgrep.identity import record_identity + from agentgrep.record_export import ExportSelectionError + + selected_identity = record_identity(snapshot.selected) + if selected_identity.thread_id is None: + message = "selected record has no observed thread" + raise ExportSelectionError(message) + return tuple( + record + for record in snapshot.records + if ( + selected_identity if record is snapshot.selected else record_identity(record) + ).thread_id + == selected_identity.thread_id + ) + + @staticmethod + def _safe_export_filename(path: pathlib.Path) -> str: + """Return one bounded control-free basename for a notification.""" + name = path.name or "export" + safe = "".join(char if char.isprintable() else "?" for char in name) + return safe[:160] or "export" + + @_runtime.pump_only + def _apply_export_completed(self, generation: int, event: object) -> None: + """Release pending state and show a path-safe terminal notification.""" + if generation != self._export_generation or not isinstance(event, _ExportCompleted): + return + self._export_pending = False + self._export_cancel_event = None + if not self.is_mounted: + return + if event.error is not None: + self.notify( + event.error, + title="Export failed", + severity="error", + ) + return + noun = "record" if event.record_count == 1 else "records" + self.notify( + f"{event.filename} · {event.format} · {event.selection} · {event.record_count} {noun}", + title="Export complete", + ) + def _has_active_actions(self) -> bool: """Return True if any cancellable in-flight action exists. diff --git a/tests/test_record_export.py b/tests/test_record_export.py index fe42e8d96..b83adb86a 100644 --- a/tests/test_record_export.py +++ b/tests/test_record_export.py @@ -553,13 +553,18 @@ def test_export_writers_reject_forged_format_without_file_side_effects( ) destination = tmp_path / ("exports" if private else "artifact.ndjson") - if private: - with pytest.raises(ExportFormatError, match="unsupported export format"): + def write_forged_artifact() -> None: + if private: write_private_export(artifact, directory=destination) - else: - with pytest.raises(ExportFormatError, match="unsupported export format"): + else: write_export(artifact, destination) + with pytest.raises(ExportFormatError, match="unsupported export format"): + write_forged_artifact() + + with pytest.raises(ExportFormatError, match="unsupported export format"): + write_forged_artifact() + assert list(tmp_path.iterdir()) == [] @@ -575,13 +580,18 @@ def test_export_writers_reject_forged_selection_without_file_side_effects( ) destination = tmp_path / ("exports" if private else "artifact.ndjson") - if private: - with pytest.raises(ExportSelectionError, match="unsupported export selection"): + def write_forged_artifact() -> None: + if private: write_private_export(artifact, directory=destination) - else: - with pytest.raises(ExportSelectionError, match="unsupported export selection"): + else: write_export(artifact, destination) + with pytest.raises(ExportSelectionError, match="unsupported export selection"): + write_forged_artifact() + + with pytest.raises(ExportSelectionError, match="unsupported export selection"): + write_forged_artifact() + assert list(tmp_path.iterdir()) == [] diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py new file mode 100644 index 000000000..7045851a5 --- /dev/null +++ b/tests/test_ui_export.py @@ -0,0 +1,760 @@ +"""Mounted export-command tests for the pi-like Textual HUD.""" + +from __future__ import annotations + +import asyncio +import collections.abc as cabc +import pathlib +import threading +import time +import typing as t + +import pytest + +from agentgrep import identity, record_export +from agentgrep.progress import SearchRequestedPayload +from agentgrep.records import RecordPosition, SearchRecord +from agentgrep.ui import _runtime +from agentgrep.ui.widgets import SearchRequested +from tests.test_agentgrep_tui_identity import _build_empty_ui_app + +pytestmark = pytest.mark.tui + + +def _search_requested(text: str) -> SearchRequested: + """Build one search request for direct HUD handler coverage.""" + return SearchRequested(payload=SearchRequestedPayload(text=text)) + + +def _record( + tmp_path: pathlib.Path, + text: str, + *, + ordinal: int, + session_id: str | None = "session-a", + source_name: str | None = None, +) -> SearchRecord: + """Build one normalized source record with deterministic identities.""" + return SearchRecord( + kind="prompt", + agent="codex", + store="codex.sessions", + adapter_id="codex.sessions_jsonl.v1", + path=tmp_path / (source_name or f"source-{ordinal}.jsonl"), + text=text, + role="user", + timestamp=f"2026-07-12T12:00:{ordinal:02d}Z", + model="gpt-test", + session_id=session_id, + conversation_id=session_id, + identity_namespace="codex.session" if session_id is not None else None, + position=RecordPosition(ordinal=ordinal, quality="source_order"), + ) + + +async def _load_records( + screen: t.Any, + records: tuple[SearchRecord, ...], + *, + selected: int = 0, +) -> None: + """Mount records through the bounded result applier and select one row.""" + await screen._apply_records_batch(records, len(records)) + selected_record = records[selected] + row_index = next( + index for index, record in enumerate(screen.filtered_records) if record is selected_record + ) + screen._results.highlighted = row_index + screen._current_detail_record = selected_record + + +async def _wait_for(predicate: t.Callable[[], bool], *, timeout: float = 3.0) -> None: + """Yield until a worker-observable condition is true.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + await asyncio.sleep(0.01) + pytest.fail("timed out waiting for export worker") + + +def _capture_notifications( + screen: t.Any, + monkeypatch: pytest.MonkeyPatch, +) -> list[tuple[tuple[object, ...], dict[str, object]]]: + """Capture HUD notifications without rendering a toast.""" + notes: list[tuple[tuple[object, ...], dict[str, object]]] = [] + monkeypatch.setattr(screen, "notify", lambda *a, **k: notes.append((a, k))) + return notes + + +@pytest.mark.slow +async def test_export_commands_accept_paths_but_legacy_args_stay_searches( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only export commands consume an argument remainder.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + requests: list[tuple[str, str]] = [] + searches: list[object] = [] + monkeypatch.setattr( + app.screen, + "request_export", + lambda path, *, selection: requests.append((selection, path)), + ) + monkeypatch.setattr(app.screen, "_start_search_worker", searches.append) + + app.screen._search_input.focus() + app.screen._search_input.value = "/export nested/result.md" + await pilot.pause() + assert app.screen._enum_dropdown.display is False + await pilot.press("enter") + app.screen.on_search_requested(_search_requested("/export-thread thread.md")) + app.screen.on_search_requested(_search_requested("/help still a query")) + await pilot.pause() + + assert requests == [ + ("records", "nested/result.md"), + ("thread", "thread.md"), + ] + assert len(searches) == 1 + + +@pytest.mark.slow +async def test_export_without_selection_is_a_path_free_error( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A command on an empty result set does not launch disk work.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + notes = _capture_notifications(app.screen, monkeypatch) + workers: list[tuple[tuple[object, ...], dict[str, object]]] = [] + monkeypatch.setattr( + app.screen, + "run_worker", + lambda *a, **k: workers.append((a, k)), + ) + + app.screen.on_search_requested(_search_requested("/export")) + await pilot.pause() + + assert workers == [] + assert len(notes) == 1 + assert notes[0][1]["severity"] == "error" + assert "select" in str(notes[0][0][0]).lower() + assert str(tmp_path) not in str(notes) + + +@pytest.mark.parametrize("explicit", [False, True], ids=("private", "explicit")) +@pytest.mark.slow +async def test_record_export_writes_markdown_and_preserves_results( + explicit: bool, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Default and explicit sinks export exactly the selected record.""" + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + app = _build_empty_ui_app(tmp_path, monkeypatch) + records = ( + _record(tmp_path, "first exact body", ordinal=1), + _record(tmp_path, "second private body", ordinal=2), + ) + destination = tmp_path / "chosen directory" / "selected record.md" + if explicit: + destination.parent.mkdir() + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, records, selected=0) + notes = _capture_notifications(app.screen, monkeypatch) + before_all = list(app.screen.all_records) + before_filtered = list(app.screen.filtered_records) + + command = f"/export {destination}" if explicit else "/export" + app.screen.on_search_requested(_search_requested(command)) + if explicit: + await _wait_for(destination.exists) + exported = destination + else: + export_dir = tmp_path / "data" / "agentgrep" / "exports" + await _wait_for(lambda: bool(list(export_dir.glob("*.md")))) + exported = next(export_dir.glob("*.md")) + await pilot.pause() + + text = exported.read_text(encoding="utf-8") + assert text.startswith("# agentgrep record export") + assert "first exact body" in text + assert "second private body" not in text + assert app.screen.all_records == before_all + assert app.screen.filtered_records == before_filtered + assert app.screen._current_detail_record is records[0] + assert len(notes) == 1 + message = str(notes[0][0][0]) + assert exported.name in message + assert str(exported.parent) not in message + assert "markdown" in message + assert "1 record" in message + + +@pytest.mark.slow +async def test_thread_export_uses_only_selected_observed_thread( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Mixed and threadless active results do not contaminate the chosen thread.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + records = ( + _record(tmp_path, "thread a first", ordinal=1, session_id="session-a"), + _record(tmp_path, "thread b", ordinal=2, session_id="session-b"), + _record(tmp_path, "thread a second", ordinal=3, session_id="session-a"), + _record(tmp_path, "threadless", ordinal=4, session_id=None), + ) + destination = tmp_path / "thread.md" + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, records, selected=2) + notes = _capture_notifications(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested(f"/export-thread {destination}")) + await _wait_for(destination.exists) + await pilot.pause() + + text = destination.read_text(encoding="utf-8") + assert text.startswith("# agentgrep observed thread export") + assert "thread a first" in text + assert "thread a second" in text + assert "thread b" not in text + assert "threadless" not in text + assert "- Record count: 2" in text + assert "- Fidelity: unordered" in text + assert "2 records" in str(notes[0][0][0]) + + +@pytest.mark.slow +async def test_thread_export_without_path_uses_private_markdown_sink( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The no-path thread command writes a collision-safe canonical artifact.""" + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + app = _build_empty_ui_app(tmp_path, monkeypatch) + records = ( + _record(tmp_path, "first", ordinal=1), + _record(tmp_path, "second", ordinal=2), + ) + export_dir = tmp_path / "data" / "agentgrep" / "exports" + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, records) + notes = _capture_notifications(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested("/export-thread")) + await _wait_for(lambda: bool(list(export_dir.glob("*.md")))) + exported = next(export_dir.glob("*.md")) + await pilot.pause() + + assert exported.name.startswith("agentgrep-agt1-") + assert exported.read_text(encoding="utf-8").startswith( + "# agentgrep observed thread export", + ) + assert exported.name in str(notes[0][0][0]) + assert str(export_dir) not in str(notes) + + +@pytest.mark.slow +async def test_thread_export_freezes_result_count_when_accepted( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A streamed turn arriving before deferred capture is not retroactively selected.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + first = _record(tmp_path, "accepted turn", ordinal=1) + late = _record(tmp_path, "late turn", ordinal=2) + destination = tmp_path / "accepted-thread.md" + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (first,)) + scheduled: list[tuple[t.Callable[..., t.Awaitable[None]], tuple[object, ...]]] = [] + + def defer( + callback: t.Callable[..., t.Awaitable[None]], + *args: object, + ) -> None: + scheduled.append((callback, args)) + + monkeypatch.setattr(app.screen, "call_later", defer) + app.screen.on_search_requested(_search_requested(f"/export-thread {destination}")) + assert len(scheduled) == 1 + + app.screen.filtered_records.append(late) + callback, args = scheduled.pop() + await callback(*args) + await _wait_for(destination.exists) + + text = destination.read_text(encoding="utf-8") + assert "accepted turn" in text + assert "late turn" not in text + assert "- Record count: 1" in text + + +@pytest.mark.slow +async def test_thread_export_rejects_threadless_selection( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A null canonical thread identity is rejected without creating a file.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "threadless", ordinal=1, session_id=None) + destination = tmp_path / "thread.md" + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (record,)) + notes = _capture_notifications(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested(f"/export-thread {destination}")) + await _wait_for(lambda: bool(notes)) + + assert not destination.exists() + assert notes[0][1]["severity"] == "error" + assert "thread" in str(notes[0][0][0]).lower() + assert str(tmp_path) not in str(notes) + + +@pytest.mark.parametrize("unsafe", ["exists", "symlink", "source"]) +@pytest.mark.slow +async def test_explicit_export_refuses_unsafe_destinations( + unsafe: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No-overwrite, no-symlink, and source-alias rules reach the TUI.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "protected body", ordinal=1) + destination = tmp_path / "destination.md" + if unsafe == "exists": + destination.write_text("keep", encoding="utf-8") + elif unsafe == "symlink": + target = tmp_path / "target.md" + target.write_text("keep", encoding="utf-8") + destination.symlink_to(target) + else: + destination = record.path + destination.write_text("source", encoding="utf-8") + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (record,)) + notes = _capture_notifications(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested(f"/export {destination}")) + await _wait_for(lambda: bool(notes)) + + assert notes[0][1]["severity"] == "error" + assert str(tmp_path) not in str(notes) + if unsafe == "exists": + assert destination.read_text(encoding="utf-8") == "keep" + elif unsafe == "symlink": + assert destination.is_symlink() + assert destination.read_text(encoding="utf-8") == "keep" + else: + assert destination.read_text(encoding="utf-8") == "source" + + +@pytest.mark.slow +async def test_unexpected_writer_error_is_path_free( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An arbitrary filesystem exception cannot leak its destination text.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "body", ordinal=1) + secret_path = tmp_path / "private-name.md" + + def fail_write(*args: object, **kwargs: object) -> pathlib.Path: + message = f"failed at {secret_path}" + raise OSError(message) + + monkeypatch.setattr(record_export, "write_export", fail_write) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (record,)) + notes = _capture_notifications(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested(f"/export {secret_path}")) + await _wait_for(lambda: bool(notes)) + + assert notes[0][1]["severity"] == "error" + assert str(secret_path) not in str(notes) + assert "could not" in str(notes[0][0][0]).lower() + + +@pytest.mark.slow +async def test_rapid_duplicate_export_is_blocked_not_superseded( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only one durable export may be accepted at a time.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "body", ordinal=1) + destination = tmp_path / "result.md" + started = threading.Event() + release = threading.Event() + real_render = record_export.render_export + calls = 0 + + def slow_render( + records: cabc.Iterable[SearchRecord], + *, + format: record_export.ExportFormat, # noqa: A002 - mirrors public API. + include_bodies: bool, + selection: record_export.ExportSelection = "records", + ) -> record_export.ExportArtifact: + nonlocal calls + calls += 1 + started.set() + assert release.wait(3) + return real_render( + records, + format=format, + include_bodies=include_bodies, + selection=selection, + ) + + monkeypatch.setattr(record_export, "render_export", slow_render) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (record,)) + notes = _capture_notifications(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested(f"/export {destination}")) + assert await asyncio.to_thread(started.wait, 2) + app.screen.on_search_requested(_search_requested(f"/export {destination}")) + await pilot.pause() + assert calls == 1 + assert any("progress" in str(note[0][0]).lower() for note in notes) + + release.set() + await _wait_for(destination.exists) + await pilot.pause() + assert calls == 1 + + +@pytest.mark.slow +async def test_record_switch_does_not_change_accepted_export( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A worker owns the exact selection captured when the command was accepted.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + records = ( + _record(tmp_path, "first selected", ordinal=1), + _record(tmp_path, "second later", ordinal=2), + ) + destination = tmp_path / "selected.md" + started = threading.Event() + release = threading.Event() + real_render = record_export.render_export + + def slow_render( + records: cabc.Iterable[SearchRecord], + *, + format: record_export.ExportFormat, # noqa: A002 - mirrors public API. + include_bodies: bool, + selection: record_export.ExportSelection = "records", + ) -> record_export.ExportArtifact: + started.set() + assert release.wait(3) + return real_render( + records, + format=format, + include_bodies=include_bodies, + selection=selection, + ) + + monkeypatch.setattr(record_export, "render_export", slow_render) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, records, selected=0) + + app.screen.on_search_requested(_search_requested(f"/export {destination}")) + assert await asyncio.to_thread(started.wait, 2) + app.screen._results.highlighted = 1 + app.screen._current_detail_record = records[1] + release.set() + await _wait_for(destination.exists) + + text = destination.read_text(encoding="utf-8") + assert "first selected" in text + assert "second later" not in text + + +@pytest.mark.slow +async def test_thread_snapshot_aborts_if_results_reset_mid_copy( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Chunk-yielded result capture never launches with a mixed-time tuple.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + records = tuple( + _record(tmp_path, f"body {index}", ordinal=index, session_id="session-a") + for index in range(1, 402) + ) + destination = tmp_path / "thread.md" + first_chunk = asyncio.Event() + continue_copy = asyncio.Event() + real_stream_apply = _runtime.stream_apply + + async def paused_stream_apply( + items: cabc.Sequence[SearchRecord], + apply_chunk: cabc.Callable[[cabc.Sequence[SearchRecord]], None], + *, + chunk_size: int = 200, + yield_between: cabc.Callable[[], cabc.Awaitable[None]] | None = None, + ) -> None: + del yield_between + + async def pause_once() -> None: + first_chunk.set() + await continue_copy.wait() + + await real_stream_apply( + items, + apply_chunk, + chunk_size=chunk_size, + yield_between=pause_once, + ) + + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, records) + monkeypatch.setattr(_runtime, "stream_apply", paused_stream_apply) + notes = _capture_notifications(app.screen, monkeypatch) + worker_calls: list[dict[str, object]] = [] + real_run_worker = app.screen.run_worker + + def track_worker(*args: object, **kwargs: object) -> object: + if kwargs.get("group") == "export": + worker_calls.append(kwargs) + return real_run_worker(*args, **kwargs) + + monkeypatch.setattr(app.screen, "run_worker", track_worker) + + app.screen.on_search_requested(_search_requested(f"/export-thread {destination}")) + await asyncio.wait_for(first_chunk.wait(), 2) + app.screen._reset_search_chrome() + continue_copy.set() + await _wait_for(lambda: bool(notes)) + + assert worker_calls == [] + assert not destination.exists() + assert "changed" in str(notes[0][0][0]).lower() + assert app.screen._export_pending is False + + +@pytest.mark.slow +async def test_teardown_cancels_export_before_write_and_drops_callback( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A suspended worker observes teardown before starting durable output.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "large body " * 200_000, ordinal=1) + destination = tmp_path / "canceled.md" + started = threading.Event() + release = threading.Event() + write_calls = 0 + real_render = record_export.render_export + real_write = record_export.write_export + + def slow_render( + records: cabc.Iterable[SearchRecord], + *, + format: record_export.ExportFormat, # noqa: A002 - mirrors public API. + include_bodies: bool, + selection: record_export.ExportSelection = "records", + ) -> record_export.ExportArtifact: + started.set() + assert release.wait(3) + return real_render( + records, + format=format, + include_bodies=include_bodies, + selection=selection, + ) + + def track_write( + artifact: record_export.ExportArtifact, + destination: str | pathlib.Path, + *, + force: bool = False, + protected_paths: cabc.Iterable[str | pathlib.Path] = (), + ) -> pathlib.Path: + nonlocal write_calls + write_calls += 1 + return real_write( + artifact, + destination, + force=force, + protected_paths=protected_paths, + ) + + monkeypatch.setattr(record_export, "render_export", slow_render) + monkeypatch.setattr(record_export, "write_export", track_write) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (record,)) + notes = _capture_notifications(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested(f"/export {destination}")) + assert await asyncio.to_thread(started.wait, 2) + app.screen.on_unmount() + release.set() + await asyncio.sleep(0.1) + + assert write_calls == 0 + assert not destination.exists() + assert notes == [] + + +@pytest.mark.slow +async def test_stale_export_callback_cannot_clear_live_pending_state( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Generation gating drops an old completion without touching a newer request.""" + from agentgrep.ui.layouts.hud import _ExportCompleted + + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + notes = _capture_notifications(app.screen, monkeypatch) + app.screen._export_generation = 8 + app.screen._export_pending = True + + app.screen._apply_export_completed( + 7, + _ExportCompleted( + filename="old.md", + format="markdown", + selection="records", + record_count=1, + error=None, + ), + ) + + assert notes == [] + assert app.screen._export_pending is True + + +@pytest.mark.slow +async def test_large_export_worker_keeps_pump_responsive( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Large body work stays off-pump while keystrokes continue to dispatch.""" + monkeypatch.setenv("AGENTGREP_TUI_WATCHDOG", "1") + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "large body\n" * 300_000, ordinal=1) + destination = tmp_path / "large.md" + started = threading.Event() + release = threading.Event() + real_render = record_export.render_export + real_write = record_export.write_export + + def slow_render( + records: cabc.Iterable[SearchRecord], + *, + format: record_export.ExportFormat, # noqa: A002 - mirrors public API. + include_bodies: bool, + selection: record_export.ExportSelection = "records", + ) -> record_export.ExportArtifact: + _runtime.assert_off_pump("export render") + started.set() + assert release.wait(3) + return real_render( + records, + format=format, + include_bodies=include_bodies, + selection=selection, + ) + + def checked_write( + artifact: record_export.ExportArtifact, + output: str | pathlib.Path, + *, + force: bool = False, + protected_paths: cabc.Iterable[str | pathlib.Path] = (), + ) -> pathlib.Path: + _runtime.assert_off_pump("export write") + return real_write( + artifact, + output, + force=force, + protected_paths=protected_paths, + ) + + monkeypatch.setattr(record_export, "render_export", slow_render) + monkeypatch.setattr(record_export, "write_export", checked_write) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (record,)) + app.screen._search_input.focus() + + app.screen.on_search_requested(_search_requested(f"/export {destination}")) + assert await asyncio.to_thread(started.wait, 2) + await pilot.press("x") + await pilot.pause() + assert app.screen._search_input.value.endswith("x") + + release.set() + await _wait_for(destination.exists) + + +@pytest.mark.slow +async def test_large_observed_thread_identity_and_output_stay_off_pump( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A many-record thread still leaves keystrokes responsive during identity work.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + records = tuple( + _record( + tmp_path, + f"turn {index} " + ("x" * 5_000), + ordinal=index, + session_id="large-thread", + ) + for index in range(1, 402) + ) + destination = tmp_path / "large-thread.md" + started = threading.Event() + release = threading.Event() + real_identity = identity.record_identity + + def slow_identity(record: SearchRecord) -> identity.RecordIdentity: + _runtime.assert_off_pump("thread identity") + if not started.is_set(): + started.set() + assert release.wait(3) + return real_identity(record) + + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, records) + await pilot.pause() + monkeypatch.setattr(identity, "record_identity", slow_identity) + app.screen._search_input.focus() + + app.screen.on_search_requested(_search_requested(f"/export-thread {destination}")) + assert await asyncio.to_thread(started.wait, 2) + await pilot.press("x") + await pilot.pause() + assert app.screen._search_input.value.endswith("x") + + release.set() + await _wait_for(destination.exists, timeout=5) + text = destination.read_text(encoding="utf-8") + assert "- Record count: 401" in text + assert text.startswith("# agentgrep observed thread export") From 3d1e07b7971572005d8aed7c177904f8f701fcef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:31:41 -0500 Subject: [PATCH 13/71] agentgrep(fix[export]): Preserve record request why: Exact record export owns a captured record and must not depend on a result list that can change before its deferred worker starts. what: - Separate request liveness from observed-thread snapshot coherence. - Keep record export alive across clear, filter, and new-search changes. - Retain result-change cancellation for observed-thread exports. --- src/agentgrep/ui/layouts/hud.py | 42 ++++++++---- tests/test_ui_export.py | 110 +++++++++++++++++++++++++++++--- 2 files changed, 130 insertions(+), 22 deletions(-) diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index fa0e3157d..a577a9e21 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -1113,19 +1113,29 @@ def _selected_export_record(self) -> SearchRecord | None: return self._current_detail_record return self.filtered_records[0] if self.filtered_records else None - def _export_snapshot_is_live( + def _export_request_is_live( self, generation: int, - active_records: list[SearchRecord], - chrome_generation: int, canceled: threading.Event, ) -> bool: - """Return whether a pump-side snapshot still describes one result view.""" + """Return whether an accepted export may still start or report.""" return ( generation == self._export_generation and self._export_pending and not canceled.is_set() and self.is_mounted + ) + + def _thread_snapshot_is_live( + self, + generation: int, + active_records: list[SearchRecord], + chrome_generation: int, + canceled: threading.Event, + ) -> bool: + """Return whether an observed-thread snapshot still has one result view.""" + return ( + self._export_request_is_live(generation, canceled) and active_records is self.filtered_records and chrome_generation == self._chrome_generation ) @@ -1143,13 +1153,16 @@ async def _snapshot_and_start_export( canceled: threading.Event, ) -> None: """Copy a coherent result view in bounded chunks, then start the worker.""" - if not self._export_snapshot_is_live( + if not self._export_request_is_live(generation, canceled): + self._abort_export_snapshot(generation, canceled, results_changed=False) + return + if selection == "thread" and not self._thread_snapshot_is_live( generation, active_records, chrome_generation, canceled, ): - self._abort_export_snapshot(generation, canceled) + self._abort_export_snapshot(generation, canceled, results_changed=True) return records: list[SearchRecord] = [] @@ -1159,7 +1172,7 @@ async def _snapshot_and_start_export( async def yield_and_gate() -> None: await asyncio.sleep(0) - if not self._export_snapshot_is_live( + if not self._thread_snapshot_is_live( generation, active_records, chrome_generation, @@ -1175,16 +1188,19 @@ async def yield_and_gate() -> None: yield_between=yield_and_gate, ) except _ExportSnapshotChangedError: - self._abort_export_snapshot(generation, canceled) + self._abort_export_snapshot(generation, canceled, results_changed=True) return - if not self._export_snapshot_is_live( + if not self._export_request_is_live(generation, canceled): + self._abort_export_snapshot(generation, canceled, results_changed=False) + return + if selection == "thread" and not self._thread_snapshot_is_live( generation, active_records, chrome_generation, canceled, ): - self._abort_export_snapshot(generation, canceled) + self._abort_export_snapshot(generation, canceled, results_changed=True) return snapshot = _ExportSnapshot( @@ -1213,15 +1229,17 @@ def _abort_export_snapshot( self, generation: int, canceled: threading.Event, + *, + results_changed: bool, ) -> None: - """Release a pre-worker export whose displayed results changed.""" + """Release a pre-worker export and optionally report result invalidation.""" canceled.set() if generation != self._export_generation: return self._export_pending = False if self._export_cancel_event is canceled: self._export_cancel_event = None - if self.is_mounted: + if results_changed and self.is_mounted: self.notify( "Export canceled because results changed", title="Export canceled", diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 7045851a5..c0e03c516 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -15,7 +15,7 @@ from agentgrep.progress import SearchRequestedPayload from agentgrep.records import RecordPosition, SearchRecord from agentgrep.ui import _runtime -from agentgrep.ui.widgets import SearchRequested +from agentgrep.ui.widgets import FilterCompleted, SearchRequested from tests.test_agentgrep_tui_identity import _build_empty_ui_app pytestmark = pytest.mark.tui @@ -88,6 +88,42 @@ def _capture_notifications( return notes +def _defer_export_start( + screen: t.Any, + monkeypatch: pytest.MonkeyPatch, +) -> list[tuple[t.Callable[..., t.Awaitable[None]], tuple[object, ...]]]: + """Capture the one pump callback scheduled by an accepted export.""" + scheduled: list[tuple[t.Callable[..., t.Awaitable[None]], tuple[object, ...]]] = [] + + def defer( + callback: t.Callable[..., t.Awaitable[None]], + *args: object, + ) -> None: + scheduled.append((callback, args)) + + monkeypatch.setattr(screen, "call_later", defer) + return scheduled + + +def _change_results(screen: t.Any, change: str, replacement: SearchRecord) -> None: + """Apply one mounted result-view change before deferred export startup.""" + if change == "reset": + screen._reset_search_chrome() + elif change == "filter": + screen._filter_input.value = "replacement" + screen.on_filter_completed( + FilterCompleted( + text="replacement", + records=[replacement], + record_ids={id(replacement)}, + generation=screen._filter_generation, + records_generation=screen._records_generation, + ), + ) + else: + screen._start_search_worker(screen._build_search_query("replacement")) + + @pytest.mark.slow async def test_export_commands_accept_paths_but_legacy_args_stay_searches( tmp_path: pathlib.Path, @@ -277,15 +313,7 @@ async def test_thread_export_freezes_result_count_when_accepted( async with app.run_test(size=(120, 30)) as pilot: await pilot.pause() await _load_records(app.screen, (first,)) - scheduled: list[tuple[t.Callable[..., t.Awaitable[None]], tuple[object, ...]]] = [] - - def defer( - callback: t.Callable[..., t.Awaitable[None]], - *args: object, - ) -> None: - scheduled.append((callback, args)) - - monkeypatch.setattr(app.screen, "call_later", defer) + scheduled = _defer_export_start(app.screen, monkeypatch) app.screen.on_search_requested(_search_requested(f"/export-thread {destination}")) assert len(scheduled) == 1 @@ -300,6 +328,68 @@ def defer( assert "- Record count: 1" in text +@pytest.mark.parametrize("change", ["reset", "filter", "new-search"]) +@pytest.mark.slow +async def test_record_export_survives_result_change_before_deferred_start( + change: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An accepted exact record is independent of later result-view changes.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + selected = _record(tmp_path, "accepted exact record", ordinal=1) + replacement = _record(tmp_path, "replacement record", ordinal=2, session_id="other") + destination = tmp_path / f"record-{change}.md" + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (selected,)) + notes = _capture_notifications(app.screen, monkeypatch) + scheduled = _defer_export_start(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested(f"/export {destination}")) + assert len(scheduled) == 1 + _change_results(app.screen, change, replacement) + callback, args = scheduled.pop() + await callback(*args) + await _wait_for(destination.exists) + await pilot.pause() + + text = destination.read_text(encoding="utf-8") + assert "accepted exact record" in text + assert "replacement record" not in text + assert not any("canceled" in str(note[0][0]).lower() for note in notes) + + +@pytest.mark.parametrize("change", ["reset", "filter", "new-search"]) +@pytest.mark.slow +async def test_thread_export_cancels_result_change_before_deferred_start( + change: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An observed-thread snapshot still requires its accepted result view.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + selected = _record(tmp_path, "accepted thread", ordinal=1) + replacement = _record(tmp_path, "replacement record", ordinal=2, session_id="other") + destination = tmp_path / f"thread-{change}.md" + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (selected,)) + notes = _capture_notifications(app.screen, monkeypatch) + scheduled = _defer_export_start(app.screen, monkeypatch) + + app.screen.on_search_requested(_search_requested(f"/export-thread {destination}")) + assert len(scheduled) == 1 + _change_results(app.screen, change, replacement) + callback, args = scheduled.pop() + await callback(*args) + await pilot.pause() + + assert not destination.exists() + assert app.screen._export_pending is False + assert any("changed" in str(note[0][0]).lower() for note in notes) + + @pytest.mark.slow async def test_thread_export_rejects_threadless_selection( tmp_path: pathlib.Path, From 8fd863dd943263358c2ae5c8626a8bf4f00e68fb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:45:33 -0500 Subject: [PATCH 14/71] agentgrep(docs[export]): Define portability why: Export spans CLI, HUD, and MCP with different body and sink authority. One public contract keeps deterministic payloads, thread fidelity, and source-store safety aligned. what: - Add reader-facing CLI, TUI, MCP guides and ADR 0017. - Document formats, privacy defaults, writer semantics, and deferred tiers. - Add release notes, public indexes, and docs contract tests. --- CHANGES | 12 + docs/cli/export.md | 109 +++++++++ docs/cli/index.md | 7 + docs/dev/adr/0017-portable-record-export.md | 171 ++++++++++++++ docs/dev/adr/index.md | 1 + docs/mcp/tools.md | 29 +++ docs/tui/index.md | 32 +++ tests/test_export_docs.py | 249 ++++++++++++++++++++ 8 files changed, 610 insertions(+) create mode 100644 docs/cli/export.md create mode 100644 docs/dev/adr/0017-portable-record-export.md create mode 100644 tests/test_export_docs.py diff --git a/CHANGES b/CHANGES index 20f1dce6b..7e2fc5f4e 100644 --- a/CHANGES +++ b/CHANGES @@ -73,6 +73,18 @@ captures a clean SVG without changing the active search. `/keys`, theme switching, filtered-row colors, focus repair, and repeated-key quit confirmation now behave consistently across the two layouts. +#### Portable record export across CLI, TUI, and MCP (#81) + +agentgrep can now turn selected search records into deterministic NDJSON or +human-readable Markdown without changing the underlying histories. The CLI +exports matching records to standard output or a chosen file, the HUD exports +one selected record or its observed thread, and MCP returns a bounded inline +artifact for existing search refs. + +Machine clients must opt in before an MCP export includes prompt or history +bodies, and file output refuses accidental replacement. See {ref}`the export +guide ` for formats, selection, and privacy details. + ## agentgrep 0.1.0a50 (2026-08-09) agentgrep 0.1.0a50 makes the store catalogue describe an agent's whole diff --git a/docs/cli/export.md b/docs/cli/export.md new file mode 100644 index 000000000..8cc94dd29 --- /dev/null +++ b/docs/cli/export.md @@ -0,0 +1,109 @@ +(cli-export)= + +# agentgrep export + +`agentgrep export` turns records matched by the shared search engine into a +portable artifact without modifying an agent's history. There are exactly two +formats. Use `ndjson` for scripts and `markdown` for reading or sharing. The +default format is `ndjson`, and the default sink is standard output. Record +bodies are included by default because running `export` is an explicit choice. + +The command accepts the same agent, scope, case, and query-language filters as +search. Terms are combined with AND semantics, and the default scope is +`prompts`. The default limit is `100`; set `--limit` to any value from `1` +through `1000`. + +## Examples + +Export matching prompt records as NDJSON to standard output: + +```console +$ agentgrep export "release notes" +``` + +Omit prompt and history text while retaining portable metadata: + +```console +$ agentgrep export "release notes" --no-bodies +``` + +Write human-readable Markdown to standard output: + +```console +$ agentgrep export "release notes" --format markdown +``` + +Search prompts and conversation records together: + +```console +$ agentgrep export "release notes" --scope all +``` + +Write NDJSON to a new relative file: + +```console +$ agentgrep export "release notes" -o records.ndjson +``` + +Replace an existing regular file deliberately: + +```console +$ agentgrep export "release notes" -o records.ndjson --force +``` + +`-o -` names standard output explicitly. `--force` is invalid with standard +output; it applies only to a file destination. + +## Formats and privacy + +NDJSON contains one canonical JSON object per record. Keys and record order +are stable, and a trailing newline separates every object. Markdown presents +the same allowlisted metadata as headings and lists. With bodies enabled, it +places exact valid UTF-8 text in a dynamically sized code fence so backticks in +the record cannot close the block. + +Both formats include the record schema version, agent, store, kind, role, +timestamp, model, content ID, optional record ID and stability, and optional +thread ID. `--no-bodies` omits the `text` field or body section entirely. +Neither format carries source or display paths, titles, session IDs, +conversation IDs, project origin, or adapter metadata. See {ref}`ADR 0017 +` for the exact allowlist and ordering contract. + +NDJSON represents lone surrogate code points as JSON escapes, so the artifact +remains valid UTF-8 and a JSON decoder can recover the original string. +Markdown cannot represent those values as valid UTF-8 and returns a path-free +encoding error instead of altering the text. + +(cli-export-files)= + +## File safety + +File output refuses to overwrite an existing destination unless `--force` is +present. Even with force, the destination must be a regular file: agentgrep +rejects a symlink, a symlinked parent, and any lexical, resolved, or hard-link +alias of a source store. The CLI protects every discovered source store, +including non-default stores and stores that did not match the query, so an +export cannot replace history by choosing it as the destination. + +The completed artifact is installed atomically with private file permissions. +Errors do not include the destination or source path. These rules preserve +agentgrep's read-only treatment of Codex, Claude Code, Cursor, and every other +source store; only the chosen export artifact is written. + +## Exit status + +The command exits `0` when at least one record is exported and `1` when the +search has no matches, producing an empty valid artifact. Invalid arguments, +source failures, encoding failures, and output failures exit `2` with a +path-free diagnostic on standard error. + +## Command + +```{eval-rst} +.. argparse:: + :module: agentgrep + :func: build_docs_parser + :prog: agentgrep + :path: export + :nodescription: +``` diff --git a/docs/cli/index.md b/docs/cli/index.md index 2ea5b7dbe..409bf49ef 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -46,6 +46,12 @@ Ranked, deduped search grouped by session — best matches first. Enumerate on-disk stores with fd-shaped flag grammar. ::: +:::{grid-item-card} agentgrep export +:link: export +:link-type: doc +Save deterministic NDJSON or Markdown without changing source histories. +::: + :::{grid-item-card} agentgrep ui :link: tui :link-type: ref @@ -172,5 +178,6 @@ $ agentgrep grep search find +export reference ``` diff --git a/docs/dev/adr/0017-portable-record-export.md b/docs/dev/adr/0017-portable-record-export.md new file mode 100644 index 000000000..77367b758 --- /dev/null +++ b/docs/dev/adr/0017-portable-record-export.md @@ -0,0 +1,171 @@ +(adr-portable-record-export)= + +# ADR 0017: Portable record export + +## Status + +Accepted. + +## Context + +Search results are useful outside agentgrep, but source histories are not an +interchange format. They contain backend-specific paths, metadata, revisions, +and partial conversation views. Copying those records wholesale would expose +more local context than the user selected and would make every upstream schema +part of agentgrep's compatibility surface. + +{ref}`ADR 0015 ` supplies portable content, +occurrence, and thread handles. Export needs a narrower contract on top: a +deterministic allowlist, honest observed-thread labels, explicit body policy, +and output rules that can never replace an agent's source store. + +## Decision + +### Formats and payload + +The initial contract has exactly two formats: `ndjson` and `markdown`. Both +render the same ordered records and the same allowlisted values: + +| Field | Meaning | +| --- | --- | +| `schema_version` | Version of the normalized record schema | +| `agent` | Source agent family | +| `store` | Stable store identifier | +| `kind` | Normalized record kind | +| `role` | Normalized role, or null | +| `timestamp` | Recorded timestamp, or null | +| `model` | Recorded model, or null | +| `content_id` | Canonical content handle | +| `record_id` | Canonical logical-occurrence handle, or null | +| `record_id_stability` | `native`, `source_order`, or null | +| `thread_id` | Canonical observed-thread handle, or null | +| `text` | Exact normalized body, only when bodies are included | + +The allowlist excludes raw and source paths, display paths, adapter metadata, +origin and working directories, repository paths, titles, session IDs, +conversation IDs, physical refs, and arbitrary record metadata. Canonical IDs +are pseudonymous equality handles rather than anonymization; omitting a body +does not make the remaining activity metadata secret. + +NDJSON is canonical: one canonical JSON object per line, stable key order, +compact separators, and a final newline. ASCII JSON escaping keeps the byte +stream valid UTF-8 and preserves lone surrogates as JSON escapes. + +Markdown emits human-readable allowlisted metadata and, when requested, the +exact body. It first validates every emitted value as UTF-8 and rejects lone +surrogates with a path-free encoding error rather than replacing or dropping +them. Each body uses a dynamic backtick fence longer than every backtick run in +that body, so valid UTF-8 text is preserved without changing fence semantics. + +### Deterministic selection and observed threads + +Record exports accept zero or more normalized records. Output is independent +of input permutation: the total order compares canonical thread ID, timestamp, +record ID or content ID, content ID, and finally the complete canonical +allowlisted payload, with missing values ordered explicitly. This inventory +order is deterministic; it does not claim chronology. + +A thread export requires every record to share one non-null canonical thread +ID. The artifact calls the selection an **observed thread** and carries one of +the conversation fidelity labels defined by ADR 0015: + +- `native_tree` means native parent facts establish observed tree structure; + it does not establish sibling order. +- `source_order` means unique comparable source ordinals establish a linear + order for the observed records. +- `unordered` means only deterministic inventory order is available. + +An observed thread does not claim completeness, does not claim chronology, +and does not assert a root, active leaf, revision, connectivity, or chosen +branch. Fidelity describes the available source evidence; it does not replace +the artifact's deterministic inventory order. Export serializes the selected +view and does not rescan a backend to invent a complete conversation. + +### Surface defaults + +Defaults express the authority of each caller: + +| Surface | Selection | Format | Bodies | Sink | +| --- | --- | --- | --- | --- | +| CLI | Search matches, up to 100 by default | NDJSON | Included | Standard output | +| TUI | Selected record, or explicit observed thread | Markdown | Included | Private export directory, or explicit path | +| MCP | One to 20 existing search refs | NDJSON | Excluded | One bounded inline response | + +The CLI accepts limits from 1 through 1000 and an explicit `-o -` standard +output sink. A file refuses overwrite unless the user supplies `--force`. + +The HUD commands `/export [PATH]` and `/export-thread [PATH]` default to private +Markdown files with bodies. The latter selects only records in the current +filtered result set whose canonical thread ID matches the selected record. +Identity, rendering, and disk work run off the Textual message pump. Only one +accepted write may be pending, and a changed result snapshot cancels an +observed-thread export instead of writing a mixed view. + +The MCP {tooliconl}`export_records` tool accepts one to 20 unique `agref1:` +search refs and no query, cursor, or local destination. It resolves refs with +the same position-aware and historical compatibility semantics as +{tooliconl}`inspect_result`, rejects duplicate physical selections, and returns +one `TextContent` artifact plus structured metadata. Bodies default to false +and require `include_bodies=true`. The UTF-8 artifact is capped at 400 KiB and +must also fit the server's response-envelope limit. {tooliconl}`search` owns +discovery and pagination. + +### Durable file output + +Explicit file output uses a same-directory private temporary file. Complete +writes precede synchronization of the file and parent directory. A fresh +destination uses an atomic no-clobber install. Only explicit force replaces an +existing regular file atomically. + +Every path component is inspected without following links. The writer refuses +symlink destinations, symlinked parent traversal, non-regular destinations, +and source-store aliases. Alias detection covers lexical, resolved, and +same-inode relationships. CLI file output protects all discovered stores, not +only matching records; a TUI explicit path protects the selected snapshot's +sources. + +The TUI-owned default export directory is mode `0700`, and artifact and +temporary files are mode `0600`. Private filenames derive only from canonical +IDs and structural metadata, never prompt text, a title, or a source path, and +collisions allocate a new name rather than replacing an older export. Errors +remain path-free. + +### Deferred tiers + +The initial feature adds no new dependency. HTML, CSV, Mermaid, provider +training profiles, and re-import stay deferred; nested metadata or richer +topology does not silently enter one of the portable formats. + +| Tier | Why it remains deferred | +| --- | --- | +| HTML | Rich rendering needs an explicit sanitization and embedded-resource policy rather than inheriting Markdown trust. | +| CSV | Nested metadata, nullable identity, and multiline bodies need a documented lossless tabular projection. | +| Mermaid | A graph would make stronger topology and completeness claims than an observed result set can support. | +| Provider training profiles | Provider-specific schemas add provider coupling and need separate redaction and consent review. | +| Re-import and revision selection | Writing or reconciling history needs a versioned trust model, provenance checks, and conflict policy. | + +These formats can build on the allowlist only after their added semantics are +specified; they are not aliases for the two accepted formats. + +## Consequences + +CLI scripts get stable NDJSON, people get readable Markdown, the HUD can save a +selection without freezing, and MCP clients can request a bounded artifact +without gaining filesystem write authority. All three surfaces use one +renderer, so privacy and ordering do not drift by frontend. + +The narrow contract intentionally leaves presentation richness and round-trip +editing out. Thread exports describe an observed unit rather than a complete +conversation, Markdown rejects text it cannot preserve as UTF-8, and file +destinations trade convenience for source-store safety. + +## Related ADRs + +- {ref}`ADR 0004 ` owns the + shared search engine and frontend-neutral result flow. +- {ref}`ADR 0006 ` owns CLI and MCP public + surface parity. +- {ref}`ADR 0011 ` owns the Textual pump and + worker boundary. +- {ref}`ADR 0015 ` owns canonical IDs and + observed conversation fidelity. diff --git a/docs/dev/adr/index.md b/docs/dev/adr/index.md index 86f53da63..1c0460897 100644 --- a/docs/dev/adr/index.md +++ b/docs/dev/adr/index.md @@ -23,6 +23,7 @@ multiple adapters or public payloads. 0013-pluggable-tui-layouts-and-workflows 0014-result-order-limit-and-streaming-merge 0015-deterministic-record-identity +0017-portable-record-export 0020-progressive-deep-search 0021-prompt-guided-conversation-routing ``` diff --git a/docs/mcp/tools.md b/docs/mcp/tools.md index 2d63921b2..7114c6b8e 100644 --- a/docs/mcp/tools.md +++ b/docs/mcp/tools.md @@ -102,6 +102,35 @@ confirmation before a patch broadens it. ```{fastmcp-tool-input} search ``` +## Portable Record Export + +```{fastmcp-tool} export_records +``` + +**Use when** you already have one to 20 unique `agref1:` search refs and need +their selected records as one portable inline artifact. The format defaults to +`ndjson`. Choose `markdown` for a human-readable artifact. Flat `records` are +the default selection. `thread` requires all refs to resolve to one non-null +canonical observed thread. + +Prompt and history bodies are private by default: `include_bodies` defaults to +false, and text appears only with `include_bodies=true`. The result carries one +`TextContent` artifact, at most 400 KiB of UTF-8, plus structured metadata for +schema, format, selection, body policy, record count, and byte count. + +The tool accepts search refs only. It has no local destination, query, or +cursor argument and never writes a server-local file. Use {tooliconl}`search` +for discovery and pagination, then pass refs from the desired page. Refs use +the same exact repeated-occurrence and historical compatibility behavior as +{tooliconl}`inspect_result`; duplicate physical selections are refused. + +The deterministic allowlist and observed-thread fidelity are shared with the +{ref}`CLI export guide `. The tool remains read-only, idempotent, +and closed-world even when bodies are requested. + +```{fastmcp-tool-input} export_records +``` + ## Time-Windowed Activity ```{fastmcp-tool} recent_sessions diff --git a/docs/tui/index.md b/docs/tui/index.md index 5c3224eef..00154100a 100644 --- a/docs/tui/index.md +++ b/docs/tui/index.md @@ -288,6 +288,38 @@ sequence outright; iTerm2, Ghostty, kitty, WezTerm and Alacritty accept it. If a paste comes back stale, that is where to look first. ::: +(tui-export)= + +## Export + +The HUD offers two pi-like slash commands: + +- `/export [PATH]` exports exactly the selected record. +- `/export-thread [PATH]` exports the selected record's observed thread from + the current result set after the in-list filter. A record without a canonical + thread handle cannot be exported as a thread. + +Without `PATH`, both commands write a collision-free Markdown artifact to +agentgrep's private export directory. Its root follows `XDG_DATA_HOME`; when +set, artifacts go under `$XDG_DATA_HOME/agentgrep/exports`, and otherwise the +standard XDG data location is used. The directory uses mode `0700`, and each +artifact uses mode `0600`. With an explicit path, the destination must be new: +the TUI refuses to overwrite an existing file and rejects symlinks or an alias +of a selected source store. Use {ref}`agentgrep export ` when an +explicit replacement is needed. + +TUI exports include bodies and use Markdown. A success notification shows only +the artifact's basename, format, selection, and record count; failures omit +local paths. Work stays off the Textual message pump. Identity, rendering, and +disk I/O all run in the export worker. A second request reports that an export +is already in progress, and an observed-thread export cancels if its result +view changes while the HUD is taking the snapshot. + +Export does not replace the loaded results or change the detail selection. +Only the new artifact is written; source stores remain read-only. See +{ref}`ADR 0017 ` for the payload, fidelity, and +file-safety contract. + ## Completion Both the search bar and the in-list filter offer diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py new file mode 100644 index 000000000..5356db417 --- /dev/null +++ b/tests/test_export_docs.py @@ -0,0 +1,249 @@ +"""Documentation contract tests for portable record export.""" + +from __future__ import annotations + +import pathlib +import re + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def _read_text(relative_path: str) -> str: + """Return one tracked documentation file's text.""" + path = _REPO_ROOT / relative_path + assert path.is_file(), f"missing documentation contract: {relative_path}" + return path.read_text(encoding="utf-8") + + +def _missing_terms(text: str, required: tuple[str, ...]) -> tuple[str, ...]: + """Return required prose terms missing after Markdown line wrapping.""" + normalized = re.sub(r"\s+", " ", text).casefold() + return tuple( + term + for term in required + if re.sub(r"\s+", " ", term).casefold() not in normalized + ) + + +def test_export_docs_are_indexed() -> None: + """The CLI guide and ADR are reachable through their public indexes.""" + cli_index = _read_text("docs/cli/index.md") + adr_index = _read_text("docs/dev/adr/index.md") + cli_guide = _read_text("docs/cli/export.md") + adr = _read_text("docs/dev/adr/0017-portable-record-export.md") + + assert re.search(r"(?m)^export$", cli_index) + assert "0017-portable-record-export" in adr_index + assert "(cli-export)=" in cli_guide + assert "(adr-portable-record-export)=" in adr + + +def test_export_cli_docs_define_defaults_and_safe_sinks() -> None: + """The headless guide names exact formats, bounds, bodies, and sinks.""" + guide = _read_text("docs/cli/export.md") + required = ( + "exactly two formats", + "`ndjson`", + "`markdown`", + "default format is `ndjson`", + "standard output", + "`-o -`", + "record bodies are included by default", + "`--no-bodies`", + "default limit is `100`", + "`1` through `1000`", + "refuses to overwrite", + "`--force`", + "regular file", + "symlink", + "source store", + "read-only", + ) + + missing = _missing_terms(guide, required) + assert not missing, f"docs/cli/export.md is missing {missing!r}" + + +def test_export_tui_docs_define_private_off_pump_workflow() -> None: + """The TUI guide covers both pi-like commands and safe notifications.""" + tui = _read_text("docs/tui/index.md") + required = ( + "`/export [PATH]`", + "`/export-thread [PATH]`", + "selected record", + "observed thread", + "current result set", + "Markdown", + "private export directory", + "`XDG_DATA_HOME`", + "`0700`", + "`0600`", + "refuses to overwrite", + "basename", + "off the Textual message pump", + "does not replace", + "read-only", + ) + + missing = _missing_terms(tui, required) + assert not missing, f"docs/tui/index.md is missing {missing!r}" + + +def test_export_mcp_docs_define_bounded_inline_contract() -> None: + """The MCP guide distinguishes selection from discovery and local writes.""" + tools = _read_text("docs/mcp/tools.md") + required = ( + "```{fastmcp-tool} export_records", + "one to 20", + "`agref1:`", + "search refs", + "defaults to `ndjson`", + "defaults to false", + "`include_bodies=true`", + "400 KiB", + "one `TextContent` artifact", + "structured metadata", + "local destination", + "query", + "cursor", + "{tooliconl}`search`", + "discovery", + "pagination", + ) + + missing = _missing_terms(tools, required) + assert not missing, f"docs/mcp/tools.md is missing {missing!r}" + + +def test_export_adr_pins_portability_privacy_and_fidelity() -> None: + """The ADR records exact payload, Unicode, ordering, and thread limits.""" + adr = _read_text("docs/dev/adr/0017-portable-record-export.md") + allowlist = ( + "`schema_version`", + "`agent`", + "`store`", + "`kind`", + "`role`", + "`timestamp`", + "`model`", + "`content_id`", + "`record_id`", + "`record_id_stability`", + "`thread_id`", + "`text`", + ) + exclusions = ( + "source paths", + "display paths", + "adapter metadata", + "origin", + "titles", + "session IDs", + "conversation IDs", + "working directories", + ) + semantics = ( + "one canonical JSON object per line", + "stable key order", + "lone surrogates as JSON escapes", + "rejects lone surrogates", + "dynamic backtick fence", + "longer than every backtick run", + "input permutation", + "canonical thread ID", + "`native_tree`", + "`source_order`", + "`unordered`", + "observed thread", + "does not claim completeness", + "does not claim chronology", + ) + + missing = _missing_terms(adr, allowlist + exclusions + semantics) + assert not missing, f"export ADR is missing {missing!r}" + + +def test_export_adr_pins_writer_and_deferred_tiers() -> None: + """The ADR keeps durable output narrow and records deferred tradeoffs.""" + adr = _read_text("docs/dev/adr/0017-portable-record-export.md") + writer = ( + "same-directory private temporary file", + "complete writes", + "file and parent directory", + "atomic no-clobber", + "explicit force", + "symlink destinations", + "source-store aliases", + "`0700`", + "`0600`", + ) + deferred = ( + "HTML", + "CSV", + "Mermaid", + "provider training profiles", + "re-import", + "sanitization", + "nested metadata", + "topology", + "provider coupling", + "conflict policy", + "no new dependency", + ) + + missing = _missing_terms(adr, writer + deferred) + assert not missing, f"export ADR is missing {missing!r}" + + +def test_export_console_examples_are_individually_copyable() -> None: + """Every export console block contains exactly one shell command.""" + guide = _read_text("docs/cli/export.md") + blocks = re.findall(r"```console\n(?P.*?)\n```", guide, flags=re.DOTALL) + + assert len(blocks) >= 5 + for block in blocks: + prompts = [line for line in block.splitlines() if line.startswith("$ ")] + assert len(prompts) == 1, f"console block is not one command: {block!r}" + + +def test_export_changelog_has_one_product_deliverable() -> None: + """The unreleased section lists issue 81 without a release summary.""" + changes = _read_text("CHANGES") + release_match = re.search( + r"^## agentgrep \d+\.\d+\.\d+\w* \(Yet to be released\)\n" + r"(?P.*?)(?=^## agentgrep |\Z)", + changes, + flags=re.MULTILINE | re.DOTALL, + ) + assert release_match is not None + release = release_match.group("body") + heading = "#### Portable record export across CLI, TUI, and MCP (#81)" + end_marker = "" + + assert end_marker in release + assert release.split(end_marker, maxsplit=1)[1].lstrip().startswith("### ") + assert release.count(heading) == 1 + assert changes.count(heading) == 1 + assert release.count("(#81)") == 1 + assert "cli-export" in release + assert all(term in release for term in ("NDJSON", "Markdown", "CLI", "HUD", "MCP")) + assert not any(term in release for term in ("fsync", "temporary file", "400 KiB")) + + +def test_export_docs_do_not_divulge_local_paths_or_prompt_text() -> None: + """New public export docs contain no host paths or real history excerpts.""" + paths = ( + "CHANGES", + "docs/cli/export.md", + "docs/cli/index.md", + "docs/dev/adr/0017-portable-record-export.md", + "docs/dev/adr/index.md", + "docs/mcp/tools.md", + "docs/tui/index.md", + ) + + for relative_path in paths: + text = _read_text(relative_path) + assert "/home/" not in text + assert "/Users/" not in text + assert "prompt body example" not in text From 78e0c325bcdaed8b8f589111268f395b75e17b54 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:54:27 -0500 Subject: [PATCH 15/71] agentgrep(docs[mcp]): Register export surface why: Sphinx collects MCP tools from the docs-only signature shim rather than the runtime server. Runtime registration alone left the export directives unresolved and omitted both public export models. what: - Mirror the bounded export_records signature and safety metadata. - Add export request and response models to collector and API reference. - Pin the collector schema, defaults, and reference coverage in tests. --- docs/_ext/agentgrep_fastmcp.py | 39 ++++++++++++++++++++++++++++ docs/conf.py | 2 ++ docs/mcp/reference.md | 4 +++ tests/test_export_docs.py | 47 ++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/docs/_ext/agentgrep_fastmcp.py b/docs/_ext/agentgrep_fastmcp.py index 862553dbc..e9c4a85b4 100644 --- a/docs/_ext/agentgrep_fastmcp.py +++ b/docs/_ext/agentgrep_fastmcp.py @@ -16,6 +16,7 @@ from agentgrep.mcp import ( AgentSelector, CatalogAgentSelector, + ExportRecordsResponse, FindToolResponse, SearchScopeName, SearchToolResponse, @@ -126,6 +127,44 @@ async def search( ) +async def export_records( + refs: t.Annotated[ + list[str], + Field( + min_length=1, + max_length=20, + description="One to 20 opaque refs returned by search.", + ), + ], + format: t.Annotated[ # noqa: A002 - public MCP argument name. + t.Literal["ndjson", "markdown"], + Field(description="Inline artifact format."), + ] = "ndjson", + selection: t.Annotated[ + t.Literal["records", "thread"], + Field(description="Export flat records or one observed thread."), + ] = "records", + include_bodies: t.Annotated[ + bool, + Field(description="Include prompt and history text in the artifact."), + ] = False, +) -> ExportRecordsResponse: + """Return selected search refs as one bounded inline artifact.""" + raise NotImplementedError(DOCS_ONLY_MESSAGE) + + +t.cast(t.Any, export_records).__fastmcp__ = types.SimpleNamespace( + name="export_records", + title="Export Records", + tags=READONLY_TAGS | {"export"}, + annotations=types.SimpleNamespace( + readOnlyHint=True, + idempotentHint=True, + openWorldHint=False, + ), +) + + async def find( pattern: t.Annotated[ str | None, diff --git a/docs/conf.py b/docs/conf.py index c6d1e2f44..9086070f9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -88,6 +88,8 @@ "SearchToolResponse", "FindRequestModel", "FindToolResponse", + "ExportRecordsRequest", + "ExportRecordsResponse", "ResultStatsModel", "SearchPageModel", "PageInfoModel", diff --git a/docs/mcp/reference.md b/docs/mcp/reference.md index 9824d19c0..8c7832c39 100644 --- a/docs/mcp/reference.md +++ b/docs/mcp/reference.md @@ -37,6 +37,10 @@ FastMCP server factory, payload models, and MCP helpers. .. autoclass:: agentgrep.mcp.FindToolResponse +.. autoclass:: agentgrep.mcp.ExportRecordsRequest + +.. autoclass:: agentgrep.mcp.ExportRecordsResponse + .. autoclass:: agentgrep.mcp.ResultStatsModel .. autoclass:: agentgrep.mcp.SearchPageModel diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 5356db417..1945058b2 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -2,8 +2,14 @@ from __future__ import annotations +import inspect import pathlib import re +import typing as t + +from pydantic import TypeAdapter + +from agentgrep.mcp import ExportRecordsResponse _REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] @@ -115,6 +121,47 @@ def test_export_mcp_docs_define_bounded_inline_contract() -> None: assert not missing, f"docs/mcp/tools.md is missing {missing!r}" +def test_export_docs_shim_registers_bounded_public_signature() -> None: + """The collector shim mirrors the bounded public MCP schema and metadata.""" + from docs._ext import agentgrep_fastmcp + + tool = agentgrep_fastmcp.export_records + parameters = inspect.signature(tool).parameters + hints = t.get_type_hints(tool, include_extras=True) + refs_schema = TypeAdapter(hints["refs"]).json_schema() + format_schema = TypeAdapter(hints["format"]).json_schema() + selection_schema = TypeAdapter(hints["selection"]).json_schema() + metadata = t.cast(t.Any, tool).__fastmcp__ + + assert tuple(parameters) == ("refs", "format", "selection", "include_bodies") + assert refs_schema["type"] == "array" + assert refs_schema["items"] == {"type": "string"} + assert refs_schema["minItems"] == 1 + assert refs_schema["maxItems"] == 20 + assert format_schema["enum"] == ["ndjson", "markdown"] + assert selection_schema["enum"] == ["records", "thread"] + assert parameters["format"].default == "ndjson" + assert parameters["selection"].default == "records" + assert parameters["include_bodies"].default is False + assert hints["return"] is ExportRecordsResponse + assert metadata.name == "export_records" + assert metadata.title == "Export Records" + assert metadata.tags == {"agentgrep", "export", "readonly"} + assert metadata.annotations.readOnlyHint is True + assert metadata.annotations.idempotentHint is True + assert metadata.annotations.openWorldHint is False + + +def test_export_models_are_in_public_docs_reference_inventory() -> None: + """Both request and response models render through config and API reference.""" + config = _read_text("docs/conf.py") + reference = _read_text("docs/mcp/reference.md") + + for model_name in ("ExportRecordsRequest", "ExportRecordsResponse"): + assert f'"{model_name}"' in config + assert f".. autoclass:: agentgrep.mcp.{model_name}" in reference + + def test_export_adr_pins_portability_privacy_and_fidelity() -> None: """The ADR records exact payload, Unicode, ordering, and thread limits.""" adr = _read_text("docs/dev/adr/0017-portable-record-export.md") From cd46008ae585fe361548c8ca9356ba43f3fb39d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 10:59:36 -0500 Subject: [PATCH 16/71] agentgrep(fix[mcp]): Advertise export tool why: The capabilities resource omitted a registered read-only tool, so MCP clients could not discover the complete server surface. what: - Add export_records to the capability inventory. - Keep registered and advertised tool names exactly aligned. --- src/agentgrep/mcp/resources.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/agentgrep/mcp/resources.py b/src/agentgrep/mcp/resources.py index 0db112029..460bb4f99 100644 --- a/src/agentgrep/mcp/resources.py +++ b/src/agentgrep/mcp/resources.py @@ -90,6 +90,7 @@ def build_capabilities() -> CapabilitiesModel: "get_store_descriptor", "inspect_record_sample", "inspect_result", + "export_records", "validate_query", ], resources=[ From 4877e070ee8321a569e98298b438712da3bea6ee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:01:25 -0500 Subject: [PATCH 17/71] agentgrep(fix[export]): Redact phase errors why: Unexpected backend or renderer failures could expose exception text that contains local source paths or prompt bodies. what: - Sanitize unexpected source, render, and output phase failures. - Preserve typed export and existing I/O diagnostics. - Prove private paths and bodies never reach stderr. --- src/agentgrep/cli/render.py | 18 ++++++++++++ tests/test_cli_export.py | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/src/agentgrep/cli/render.py b/src/agentgrep/cli/render.py index b053f85c6..8d52e5633 100644 --- a/src/agentgrep/cli/render.py +++ b/src/agentgrep/cli/render.py @@ -216,12 +216,25 @@ def run_export_command(args: ExportArgs) -> int: except OSError: _write_export_error("export source could not be read") return 2 + except Exception: + _write_export_error("export source could not be read") + return 2 try: artifact = render_export( records, format=args.format, include_bodies=args.include_bodies, ) + except ExportError as exc: + _write_export_error(str(exc)) + return 2 + except OSError, UnicodeError: + _write_export_error("export output could not be written") + return 2 + except Exception: + _write_export_error("export artifact could not be rendered") + return 2 + try: if args.output == "-": _write_export_stdout(artifact.text) else: @@ -239,6 +252,11 @@ def run_export_command(args: ExportArgs) -> int: _silence_broken_stdout() _write_export_error("export output could not be written") return 2 + except Exception: + if args.output == "-": + _silence_broken_stdout() + _write_export_error("export output could not be written") + return 2 return 0 if records else 1 diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index f7782ec01..2aa0fa4cd 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -436,6 +436,62 @@ def fail_discovery(*_args: object, **_kwargs: object) -> t.NoReturn: assert "Traceback" not in error +@pytest.mark.parametrize( + ("phase", "expected_error"), + ( + ("search", "export source could not be read"), + ("discovery", "export source could not be read"), + ("render", "export artifact could not be rendered"), + ("output", "export output could not be written"), + ), +) +def test_export_unexpected_failures_are_path_and_body_free( + export_home: pathlib.Path, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + phase: str, + expected_error: str, +) -> None: + """Unexpected phase failures expose neither store paths nor record bodies.""" + import agentgrep.record_export as record_export + + monkeypatch.setenv("HOME", str(export_home)) + monkeypatch.setenv("CODEX_HOME", str(export_home / ".codex")) + private_path = export_home / ".codex" / "private-store.jsonl" + private_body = "private prompt body" + destination = tmp_path / "private-destination.ndjson" + + def fail(*_args: object, **_kwargs: object) -> t.NoReturn: + message = f"failed near {private_path}: {private_body}" + raise RuntimeError(message) + + if phase == "search": + monkeypatch.setattr(cli_render, "run_search_query", fail) + elif phase == "discovery": + monkeypatch.setattr(cli_render, "discover_sources", fail) + elif phase == "render": + monkeypatch.setattr(record_export, "render_export", fail) + else: + monkeypatch.setattr(record_export, "write_export", fail) + + argv = ["export", "bliss", "--agent", "codex"] + if phase in {"discovery", "output"}: + argv.extend(("-o", str(destination))) + parsed = agentgrep.parse_args(argv) + assert isinstance(parsed, agentgrep.ExportArgs) + + result = agentgrep.run_export_command(parsed) + + assert result == 2 + error = capsys.readouterr().err + assert expected_error in error + assert str(private_path) not in error + assert str(destination) not in error + assert private_body not in error + assert "Traceback" not in error + + def test_export_stdout_skips_protection_discovery( export_home: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From 33bb09597edf5bde19a1832dd3d5222288d7484f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:03:48 -0500 Subject: [PATCH 18/71] agentgrep(fix[mcp]): Reject unsafe ref paths why: A forged opaque ref with a NUL-bearing path reached discovery and then leaked a pathlib exception through both ref consumers. what: - Normalize ref paths before source discovery. - Treat path normalization failures as an unresolved source. - Prove inspect and export stay path-free and skip discovery. --- src/agentgrep/mcp/resolver.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/agentgrep/mcp/resolver.py b/src/agentgrep/mcp/resolver.py index 007081bc3..8eddee578 100644 --- a/src/agentgrep/mcp/resolver.py +++ b/src/agentgrep/mcp/resolver.py @@ -42,13 +42,14 @@ class _ParsedRequest: index: int ref: str parsed: refs.ParsedRecordRef + path_key: pathlib.Path def _path_key(path: pathlib.Path) -> pathlib.Path | None: """Return a normalized lookup path or ``None`` on unsafe input.""" try: return path.resolve() - except OSError, RuntimeError: + except OSError, RuntimeError, ValueError: return None @@ -189,7 +190,22 @@ def resolve_record_refs( error_message=f"invalid ref: {exc}", ) else: - parsed_requests.append(_ParsedRequest(index=index, ref=ref, parsed=parsed)) + path = _path_key(parsed.path) + if path is None: + results[index] = ResolvedRecordRef( + ref=ref, + kind=parsed.kind, + error_message="source not found", + ) + else: + parsed_requests.append( + _ParsedRequest( + index=index, + ref=ref, + parsed=parsed, + path_key=path, + ), + ) if parsed_requests: sources = _discover_sources(home) @@ -199,15 +215,7 @@ def resolve_record_refs( list[_ParsedRequest], ] = {} for item in parsed_requests: - path = _path_key(item.parsed.path) - if path is None: - results[item.index] = ResolvedRecordRef( - ref=item.ref, - kind=item.parsed.kind, - error_message="source not found", - ) - continue - key = (item.parsed.adapter_id, path) + key = (item.parsed.adapter_id, item.path_key) source = indexed_sources.get(key) if source is None: results[item.index] = ResolvedRecordRef( From 40994031bd59be03e0d91ba4de3ac86c71a60b7b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:07:09 -0500 Subject: [PATCH 19/71] agentgrep(fix[mcp]): Index ref fingerprints why: Resolving several refs from one source repeated text hashing and fingerprint preparation for every unresolved ref and scanned record. what: - Prepare current and historical fingerprints once per record. - Index unresolved requests by fingerprint for direct matching. - Preserve positional, historical, alias, and duplicate semantics. --- src/agentgrep/mcp/refs.py | 17 +++++++++++------ src/agentgrep/mcp/resolver.py | 31 ++++++++++++++++--------------- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/agentgrep/mcp/refs.py b/src/agentgrep/mcp/refs.py index 6815f452f..67f142556 100644 --- a/src/agentgrep/mcp/refs.py +++ b/src/agentgrep/mcp/refs.py @@ -211,16 +211,21 @@ def search_record_fingerprint( return _record_fingerprint(payload) -def search_record_fingerprint_matches(record: SearchRecordLike, fingerprint: str) -> bool: - """Match current fields with a position-blind v1 fallback.""" +def search_record_fingerprint_candidates(record: SearchRecordLike) -> tuple[str, ...]: + """Prepare current and position-blind v1 fingerprints once for a record.""" text_sha256 = hashlib.sha256( record.text.encode("utf-8", "surrogatepass"), ).hexdigest() - if search_record_fingerprint(record, text_sha256=text_sha256) == fingerprint: - return True + current = search_record_fingerprint(record, text_sha256=text_sha256) if _search_record_coordinate(record) is None: - return False - return _legacy_search_record_fingerprint(record, text_sha256=text_sha256) == fingerprint + return (current,) + legacy = _legacy_search_record_fingerprint(record, text_sha256=text_sha256) + return (current, legacy) + + +def search_record_fingerprint_matches(record: SearchRecordLike, fingerprint: str) -> bool: + """Match current fields with a position-blind v1 fallback.""" + return fingerprint in search_record_fingerprint_candidates(record) def find_record_fingerprint(record: FindRecordLike) -> str: diff --git a/src/agentgrep/mcp/resolver.py b/src/agentgrep/mcp/resolver.py index 8eddee578..861e2b472 100644 --- a/src/agentgrep/mcp/resolver.py +++ b/src/agentgrep/mcp/resolver.py @@ -100,19 +100,19 @@ def _resolve_source_group( """Resolve every request for one source in a single record scan.""" search_requests = [item for item in requests if item.parsed.kind == "search"] find_requests = [item for item in requests if item.parsed.kind == "find"] - unresolved_search = {item.index: item for item in search_requests} + unresolved_search: dict[str, list[_ParsedRequest]] = {} + for item in search_requests: + unresolved_search.setdefault(item.parsed.fingerprint, []).append(item) + unresolved_count = len(search_requests) find_records: list[SearchRecordLike] = [] read_failed = False try: for record_ordinal, record in enumerate(agentgrep.iter_source_records(source)): if len(find_records) < sample_size: find_records.append(record) - for index, item in tuple(unresolved_search.items()): - if refs.search_record_fingerprint_matches( - record, - item.parsed.fingerprint, - ): - results[index] = ResolvedRecordRef( + for fingerprint in refs.search_record_fingerprint_candidates(record): + for item in unresolved_search.pop(fingerprint, ()): + results[item.index] = ResolvedRecordRef( ref=item.ref, kind="search", records=(record,), @@ -121,18 +121,19 @@ def _resolve_source_group( record_ordinal=record_ordinal, ), ) - del unresolved_search[index] - if not unresolved_search and (not find_requests or len(find_records) >= sample_size): + unresolved_count -= 1 + if unresolved_count == 0 and (not find_requests or len(find_records) >= sample_size): break except Exception: read_failed = True - for item in unresolved_search.values(): - results[item.index] = ResolvedRecordRef( - ref=item.ref, - kind="search", - error_message="source could not be read" if read_failed else "record not found", - ) + for items in unresolved_search.values(): + for item in items: + results[item.index] = ResolvedRecordRef( + ref=item.ref, + kind="search", + error_message="source could not be read" if read_failed else "record not found", + ) for item in find_requests: if read_failed: results[item.index] = ResolvedRecordRef( From 9828ec23b4ca6240a4492e1d98065ac6464cf56b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:12:06 -0500 Subject: [PATCH 20/71] agentgrep(fix[mcp]): Bound opaque refs why: Unbounded opaque refs could consume excessive decode and audit work, and validation errors risked exposing their sensitive coordinates. what: - Share an 8192-character record-ref ceiling across both tools. - Enforce and publish the bound before decode or discovery. - Cap sensitive audit hashing and list summarization work. --- docs/_ext/agentgrep_fastmcp.py | 8 ++++- docs/dev/adr/0017-portable-record-export.md | 4 +++ docs/mcp/tools.md | 2 ++ pyproject.toml | 6 ++-- src/agentgrep/mcp/middleware.py | 33 ++++++++++++++++----- src/agentgrep/mcp/models.py | 10 +++++-- src/agentgrep/mcp/refs.py | 11 +++++++ src/agentgrep/mcp/tools/catalog_tools.py | 20 ++++++++++--- src/agentgrep/mcp/tools/export_tools.py | 7 ++++- tests/test_cli_export.py | 6 ++-- tests/test_export_docs.py | 7 ++++- 11 files changed, 92 insertions(+), 22 deletions(-) diff --git a/docs/_ext/agentgrep_fastmcp.py b/docs/_ext/agentgrep_fastmcp.py index e9c4a85b4..5a4af7654 100644 --- a/docs/_ext/agentgrep_fastmcp.py +++ b/docs/_ext/agentgrep_fastmcp.py @@ -32,6 +32,7 @@ StoreDescriptorModel, ValidateQueryResponse, ) +from agentgrep.mcp.refs import MAX_RECORD_REF_CHARS from agentgrep.query.help import query_language_summary READONLY_TAGS = {"readonly", "agentgrep"} @@ -129,7 +130,12 @@ async def search( async def export_records( refs: t.Annotated[ - list[str], + list[ + t.Annotated[ + str, + Field(min_length=1, max_length=MAX_RECORD_REF_CHARS), + ] + ], Field( min_length=1, max_length=20, diff --git a/docs/dev/adr/0017-portable-record-export.md b/docs/dev/adr/0017-portable-record-export.md index 77367b758..e52d06102 100644 --- a/docs/dev/adr/0017-portable-record-export.md +++ b/docs/dev/adr/0017-portable-record-export.md @@ -110,6 +110,10 @@ and require `include_bodies=true`. The UTF-8 artifact is capped at 400 KiB and must also fit the server's response-envelope limit. {tooliconl}`search` owns discovery and pagination. +Each opaque search ref is limited to 8,192 characters. Both MCP consumers +enforce that bound before token decoding or source discovery, and audit +redaction hashes only a bounded prefix of oversized sensitive inputs. + ### Durable file output Explicit file output uses a same-directory private temporary file. Complete diff --git a/docs/mcp/tools.md b/docs/mcp/tools.md index 7114c6b8e..318701e3f 100644 --- a/docs/mcp/tools.md +++ b/docs/mcp/tools.md @@ -123,6 +123,8 @@ cursor argument and never writes a server-local file. Use {tooliconl}`search` for discovery and pagination, then pass refs from the desired page. Refs use the same exact repeated-occurrence and historical compatibility behavior as {tooliconl}`inspect_result`; duplicate physical selections are refused. +Each ref is limited to 8,192 characters and is rejected before decoding or +source discovery when it exceeds that bound. The deterministic allowlist and observed-thread fidelity are shared with the {ref}`CLI export guide `. The tool remains read-only, idempotent, diff --git a/pyproject.toml b/pyproject.toml index 07f2b23de..6216156c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -266,8 +266,10 @@ convention = "numpy" [tool.ruff.lint.per-file-ignores] "*/__init__.py" = ["F401"] -# These modules own intentional terminal output. -"src/agentgrep/cli/render.py" = ["T201"] +# This module owns intentional terminal output. Its export phase boundaries +# also redact arbitrary adapter, renderer, and output failures before they can +# expose store paths or record bodies on stderr. +"src/agentgrep/cli/render.py" = ["T201", "BLE001"] # typer.Option / typer.Argument calls are metadata, not real callables — # B008 does not apply. E501 is suppressed because a few error messages # (e.g. the mutually-exclusive-selector message in _select_targets) read diff --git a/src/agentgrep/mcp/middleware.py b/src/agentgrep/mcp/middleware.py index ef64e0e90..67583b552 100644 --- a/src/agentgrep/mcp/middleware.py +++ b/src/agentgrep/mcp/middleware.py @@ -24,6 +24,8 @@ from fastmcp.tools.base import ToolResult from mcp import McpError +from agentgrep.mcp.refs import MAX_RECORD_REF_CHARS + if t.TYPE_CHECKING: from agentgrep.mcp.models import SearchToolResponse @@ -43,6 +45,7 @@ """ _MAX_LOGGED_STR_LEN: int = 200 +_MAX_SENSITIVE_LIST_ITEMS: int = 20 _MAX_VALIDATION_ISSUES: int = 3 _MAX_VALIDATION_DETAIL_LEN: int = 160 _FASTMCP_SERVER_LOGGER_NAME = "fastmcp.server.server" @@ -356,21 +359,27 @@ def _redact_digest(value: str) -> dict[str, t.Any]: >>> _redact_digest("") {'len': 0, 'sha256_prefix': 'e3b0c44298fc'} """ - return { + truncated = len(value) > MAX_RECORD_REF_CHARS + digest_value = value[:MAX_RECORD_REF_CHARS] if truncated else value + summary: dict[str, t.Any] = { "len": len(value), "sha256_prefix": hashlib.sha256( - value.encode("utf-8", "surrogatepass"), + digest_value.encode("utf-8", "surrogatepass"), ).hexdigest()[:12], } + if truncated: + summary["truncated"] = True + return summary def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: """Summarize tool arguments for audit logging. - Sensitive scalars get replaced by a digest dict. Sensitive list payloads - (e.g. ``terms`` is ``list[str]``) get each string element digested; invalid - non-string members expose only their type. Long non-sensitive strings get - truncated with a marker. Everything else passes through as-is. + Sensitive scalars get replaced by a bounded digest dict. Sensitive list + payloads (e.g. ``terms`` is ``list[str]``) get each string element digested + up to a fixed item cap; invalid non-string members expose only their type. + Long non-sensitive strings get truncated with a marker. Everything else + passes through as-is. Examples -------- @@ -411,10 +420,18 @@ def _summarize_args(args: dict[str, t.Any]) -> dict[str, t.Any]: if key in _SENSITIVE_ARG_NAMES and isinstance(value, str): summary[key] = _redact_digest(value) elif key in _SENSITIVE_ARG_NAMES and isinstance(value, list): - summary[key] = [ + items = [ _redact_digest(item) if isinstance(item, str) else {"type": type(item).__name__} - for item in value + for item in value[:_MAX_SENSITIVE_LIST_ITEMS] ] + if len(value) > _MAX_SENSITIVE_LIST_ITEMS: + summary[key] = { + "len": len(value), + "items": items, + "truncated": True, + } + else: + summary[key] = items elif key in _SENSITIVE_ARG_NAMES: summary[key] = {"type": type(value).__name__} elif isinstance(value, str) and len(value) > _MAX_LOGGED_STR_LEN: diff --git a/src/agentgrep/mcp/models.py b/src/agentgrep/mcp/models.py index d3e1f9f66..bb6e0fa95 100644 --- a/src/agentgrep/mcp/models.py +++ b/src/agentgrep/mcp/models.py @@ -19,6 +19,7 @@ SourceHandleLike, agentgrep, ) +from agentgrep.mcp.refs import MAX_RECORD_REF_CHARS if t.TYPE_CHECKING: from agentgrep._query_gate import UnregisteredFieldToken @@ -706,7 +707,7 @@ class InspectSampleRequest(AgentGrepModel): class InspectResultRequest(AgentGrepModel): """Validated inspect-result request payload.""" - ref: str = Field(min_length=1) + ref: str = Field(min_length=1, max_length=MAX_RECORD_REF_CHARS) sample_size: int = Field(default=1, ge=1, le=20) @@ -733,7 +734,12 @@ class InspectResultResponse(AgentGrepModel): class ExportRecordsRequest(AgentGrepModel): """Validated bounded inline-export request.""" - refs: list[str] = Field(min_length=1, max_length=20) + refs: list[ + t.Annotated[ + str, + Field(min_length=1, max_length=MAX_RECORD_REF_CHARS), + ] + ] = Field(min_length=1, max_length=20) format: t.Literal["ndjson", "markdown"] = "ndjson" selection: t.Literal["records", "thread"] = "records" include_bodies: bool = False diff --git a/src/agentgrep/mcp/refs.py b/src/agentgrep/mcp/refs.py index 67f142556..4b34283e7 100644 --- a/src/agentgrep/mcp/refs.py +++ b/src/agentgrep/mcp/refs.py @@ -19,6 +19,14 @@ _REF_PREFIX = "agref1:" _FIND_CURSOR_PREFIX = "agcur1:" +MAX_RECORD_REF_CHARS = 8192 +"""Maximum opaque record-ref length accepted at MCP boundaries. + +A common Linux ``PATH_MAX`` path expands to less than 5.5 KiB in base64url; +8 KiB leaves room for the versioned JSON coordinates while bounding decode and +audit work on untrusted input. +""" + class McpTokenError(ValueError): """Raised when an MCP ref or cursor token cannot be parsed.""" @@ -285,6 +293,9 @@ def make_find_ref(record: FindRecordLike) -> str: def parse_record_ref(ref: str, *, home: pathlib.Path) -> ParsedRecordRef: """Parse an opaque result ref.""" + if len(ref) > MAX_RECORD_REF_CHARS: + msg = "ref exceeds maximum length" + raise McpTokenError(msg) payload = _decode_token(_REF_PREFIX, ref) version = payload.get("v") if not isinstance(version, int) or isinstance(version, bool) or version != 1: diff --git a/src/agentgrep/mcp/tools/catalog_tools.py b/src/agentgrep/mcp/tools/catalog_tools.py index c34d47c4c..34bc9045a 100644 --- a/src/agentgrep/mcp/tools/catalog_tools.py +++ b/src/agentgrep/mcp/tools/catalog_tools.py @@ -7,7 +7,7 @@ import typing as t from fastmcp.exceptions import ToolError -from pydantic import Field +from pydantic import Field, ValidationError from agentgrep.mcp import resolver from agentgrep.mcp._library import ( @@ -27,6 +27,7 @@ SearchRecordModel, StoreDescriptorModel, ) +from agentgrep.mcp.refs import MAX_RECORD_REF_CHARS from agentgrep.store_catalog import CATALOG if t.TYPE_CHECKING: @@ -266,8 +267,15 @@ async def inspect_record_sample_tool( ) async def inspect_result_tool( ref: t.Annotated[ - str, - Field(min_length=1, description="Opaque ref from a search or find result."), + t.Any, + Field( + description="Opaque ref from a search or find result.", + json_schema_extra={ + "type": "string", + "minLength": 1, + "maxLength": MAX_RECORD_REF_CHARS, + }, + ), ], sample_size: t.Annotated[ int, @@ -279,7 +287,11 @@ async def inspect_result_tool( ), ] = 1, ) -> InspectResultResponse: - request = InspectResultRequest(ref=ref, sample_size=sample_size) + try: + request = InspectResultRequest(ref=ref, sample_size=sample_size) + except ValidationError: + message = "invalid inspect request" + raise ToolError(message) from None return await asyncio.to_thread(_inspect_result_sync, request) _ = inspect_result_tool diff --git a/src/agentgrep/mcp/tools/export_tools.py b/src/agentgrep/mcp/tools/export_tools.py index 7a48d3b72..3c2b128ee 100644 --- a/src/agentgrep/mcp/tools/export_tools.py +++ b/src/agentgrep/mcp/tools/export_tools.py @@ -17,6 +17,7 @@ TOOL_ANNOTATIONS, ) from agentgrep.mcp.models import ExportRecordsRequest, ExportRecordsResponse +from agentgrep.mcp.refs import MAX_RECORD_REF_CHARS from agentgrep.mcp.resolver import ( PhysicalRecordSelection, RecordRefResolverError, @@ -112,7 +113,11 @@ async def export_records_tool( description="One to 20 opaque refs returned by search.", json_schema_extra={ "type": "array", - "items": {"type": "string"}, + "items": { + "type": "string", + "minLength": 1, + "maxLength": MAX_RECORD_REF_CHARS, + }, "minItems": 1, "maxItems": 20, }, diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 2aa0fa4cd..009d4b927 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -438,12 +438,12 @@ def fail_discovery(*_args: object, **_kwargs: object) -> t.NoReturn: @pytest.mark.parametrize( ("phase", "expected_error"), - ( + [ ("search", "export source could not be read"), ("discovery", "export source could not be read"), ("render", "export artifact could not be rendered"), ("output", "export output could not be written"), - ), + ], ) def test_export_unexpected_failures_are_path_and_body_free( export_home: pathlib.Path, @@ -454,7 +454,7 @@ def test_export_unexpected_failures_are_path_and_body_free( expected_error: str, ) -> None: """Unexpected phase failures expose neither store paths nor record bodies.""" - import agentgrep.record_export as record_export + from agentgrep import record_export monkeypatch.setenv("HOME", str(export_home)) monkeypatch.setenv("CODEX_HOME", str(export_home / ".codex")) diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 1945058b2..8399f868c 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -107,6 +107,7 @@ def test_export_mcp_docs_define_bounded_inline_contract() -> None: "defaults to false", "`include_bodies=true`", "400 KiB", + "8,192 characters", "one `TextContent` artifact", "structured metadata", "local destination", @@ -135,7 +136,11 @@ def test_export_docs_shim_registers_bounded_public_signature() -> None: assert tuple(parameters) == ("refs", "format", "selection", "include_bodies") assert refs_schema["type"] == "array" - assert refs_schema["items"] == {"type": "string"} + assert refs_schema["items"] == { + "maxLength": 8192, + "minLength": 1, + "type": "string", + } assert refs_schema["minItems"] == 1 assert refs_schema["maxItems"] == 20 assert format_schema["enum"] == ["ndjson", "markdown"] From 2a36cc33ecf34e7e2bef5a55389def6672de54c5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:14:17 -0500 Subject: [PATCH 21/71] agentgrep(fix[tui]): Keep filenames literal why: A printable export basename containing Rich brackets could style or spoof the success toast instead of appearing as literal text. what: - Disable markup for successful export notifications. - Cover bracket-bearing filenames in a mounted Textual app. --- src/agentgrep/ui/layouts/hud.py | 1 + tests/test_ui_export.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index a577a9e21..1c62e7bfe 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -1357,6 +1357,7 @@ def _apply_export_completed(self, generation: int, event: object) -> None: self.notify( f"{event.filename} · {event.format} · {event.selection} · {event.record_count} {noun}", title="Export complete", + markup=False, ) def _has_active_actions(self) -> bool: diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index c0e03c516..1dd0e1ddb 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -738,6 +738,36 @@ async def test_stale_export_callback_cannot_clear_live_pending_state( assert app.screen._export_pending is True +@pytest.mark.slow +async def test_export_success_notification_treats_filename_as_literal( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Printable bracket markup in an exported basename stays literal.""" + from agentgrep.ui.layouts.hud import _ExportCompleted + + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + notes = _capture_notifications(app.screen, monkeypatch) + app.screen._export_generation = 1 + app.screen._export_pending = True + + app.screen._apply_export_completed( + 1, + _ExportCompleted( + filename="[bold]spoof[/].md", + format="markdown", + selection="records", + record_count=1, + error=None, + ), + ) + + assert notes[0][0][0] == "[bold]spoof[/].md · markdown · records · 1 record" + assert notes[0][1]["markup"] is False + + @pytest.mark.slow async def test_large_export_worker_keeps_pump_responsive( tmp_path: pathlib.Path, From 8fd962471177071a2a9be332e990f2f031ad8758 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:16:26 -0500 Subject: [PATCH 22/71] agentgrep(fix[export]): Version Markdown why: Markdown promised the portable schema allowlist but omitted its schema version, leaving zero-record artifacts without a version marker. what: - Emit the schema version once in every Markdown header. - Cover record and thread cardinality/body permutations. - Exercise the documented format against the runtime renderer. --- src/agentgrep/record_export.py | 7 +++++- tests/test_export_docs.py | 20 +++++++++++++--- tests/test_record_export.py | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/agentgrep/record_export.py b/src/agentgrep/record_export.py index 9d408a053..1ba0b9026 100644 --- a/src/agentgrep/record_export.py +++ b/src/agentgrep/record_export.py @@ -192,7 +192,12 @@ def _render_markdown( ) -> str: """Render allowlisted human-readable Markdown.""" noun = "observed thread" if selection == "thread" else "record" - lines = [f"# agentgrep {noun} export", "", f"- Selection: {selection}"] + lines = [ + f"# agentgrep {noun} export", + "", + f"- Schema version: {SCHEMA_VERSION}", + f"- Selection: {selection}", + ] lines.append(f"- Record count: {len(records)}") if thread_id is not None: lines.append(f"- Thread ID: {_markdown_scalar(thread_id)}") diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 8399f868c..81ed63bc7 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -25,9 +25,7 @@ def _missing_terms(text: str, required: tuple[str, ...]) -> tuple[str, ...]: """Return required prose terms missing after Markdown line wrapping.""" normalized = re.sub(r"\s+", " ", text).casefold() return tuple( - term - for term in required - if re.sub(r"\s+", " ", term).casefold() not in normalized + term for term in required if re.sub(r"\s+", " ", term).casefold() not in normalized ) @@ -157,6 +155,22 @@ def test_export_docs_shim_registers_bounded_public_signature() -> None: assert metadata.annotations.openWorldHint is False +def test_documented_markdown_runtime_emits_schema_version_once() -> None: + """The documented Markdown format carries its schema contract at runtime.""" + from agentgrep.record_export import render_export + + artifact = render_export((), format="markdown", include_bodies=False) + + assert artifact.text.splitlines()[:5] == [ + "# agentgrep record export", + "", + "- Schema version: agentgrep.v1", + "- Selection: records", + "- Record count: 0", + ] + assert artifact.text.count("Schema version: agentgrep.v1") == 1 + + def test_export_models_are_in_public_docs_reference_inventory() -> None: """Both request and response models render through config and API reference.""" config = _read_text("docs/conf.py") diff --git a/tests/test_record_export.py b/tests/test_record_export.py index b83adb86a..e57077713 100644 --- a/tests/test_record_export.py +++ b/tests/test_record_export.py @@ -106,6 +106,48 @@ def test_render_export_covers_cardinality_format_and_body_permutations( assert artifact.text.startswith("# agentgrep record export\n") +@pytest.mark.parametrize("selection", ("records", "thread")) +@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) +@pytest.mark.parametrize("record_count", (0, 1, 3), ids=("zero", "one", "many")) +def test_markdown_schema_version_is_once_per_artifact_permutation( + selection: record_export.ExportSelection, + include_bodies: bool, + record_count: int, +) -> None: + """Every Markdown selection emits one artifact-level schema version.""" + records = tuple( + _record( + f"body-{index}", + session_id="session-1", + position=RecordPosition(ordinal=index, quality="source_order"), + ) + for index in range(record_count) + ) + + if selection == "thread" and record_count == 0: + with pytest.raises(ExportSelectionError, match="exactly one observed thread"): + render_export( + records, + format="markdown", + include_bodies=include_bodies, + selection=selection, + ) + return + + artifact = render_export( + records, + format="markdown", + include_bodies=include_bodies, + selection=selection, + ) + + assert artifact.text.splitlines().count("- Schema version: agentgrep.v1") == 1 + if record_count: + assert artifact.text.index("- Schema version:") < artifact.text.index("## Record 1") + for index in range(record_count): + assert (f"body-{index}" in artifact.text) is include_bodies + + @pytest.mark.parametrize( ("kind", "role", "timestamp", "model"), tuple( From 21dc56b6e0363b530f9b693a1784d1a74dafc0ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:34:48 -0500 Subject: [PATCH 23/71] agentgrep(fix[mcp]): Fit PATH_MAX refs why: Worst-case JSON escaping expands a valid Linux PATH_MAX source coordinate beyond the earlier opaque-ref ceiling. what: - Raise the shared bound to 48 KiB with its byte derivation. - Round-trip search and find refs carrying escaped PATH_MAX paths. - Synchronize model, schema, audit, and reader-facing bounds. --- docs/dev/adr/0017-portable-record-export.md | 7 +++++-- docs/mcp/tools.md | 5 +++-- src/agentgrep/mcp/refs.py | 10 ++++++---- tests/test_export_docs.py | 4 ++-- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/dev/adr/0017-portable-record-export.md b/docs/dev/adr/0017-portable-record-export.md index e52d06102..c070982b5 100644 --- a/docs/dev/adr/0017-portable-record-export.md +++ b/docs/dev/adr/0017-portable-record-export.md @@ -110,8 +110,11 @@ and require `include_bodies=true`. The UTF-8 artifact is capped at 400 KiB and must also fit the server's response-envelope limit. {tooliconl}`search` owns discovery and pagination. -Each opaque search ref is limited to 8,192 characters. Both MCP consumers -enforce that bound before token decoding or source discovery, and audit +Each opaque search ref is limited to 49,152 characters (48 KiB). Linux +`PATH_MAX` leaves 4,095 path bytes after its trailing NUL; worst-case JSON +escaping expands those bytes sixfold and base64url adds another four-thirds, +for 32,760 characters before the versioned envelope. Both MCP consumers +enforce the ceiling before token decoding or source discovery, and audit redaction hashes only a bounded prefix of oversized sensitive inputs. ### Durable file output diff --git a/docs/mcp/tools.md b/docs/mcp/tools.md index 318701e3f..0946d943d 100644 --- a/docs/mcp/tools.md +++ b/docs/mcp/tools.md @@ -123,8 +123,9 @@ cursor argument and never writes a server-local file. Use {tooliconl}`search` for discovery and pagination, then pass refs from the desired page. Refs use the same exact repeated-occurrence and historical compatibility behavior as {tooliconl}`inspect_result`; duplicate physical selections are refused. -Each ref is limited to 8,192 characters and is rejected before decoding or -source discovery when it exceeds that bound. +Each ref is limited to 49,152 characters (48 KiB) and is rejected before +decoding or source discovery when it exceeds that bound. The ceiling fits a +Linux `PATH_MAX` path after worst-case JSON escaping and base64url encoding. The deterministic allowlist and observed-thread fidelity are shared with the {ref}`CLI export guide `. The tool remains read-only, idempotent, diff --git a/src/agentgrep/mcp/refs.py b/src/agentgrep/mcp/refs.py index 4b34283e7..291d3cf91 100644 --- a/src/agentgrep/mcp/refs.py +++ b/src/agentgrep/mcp/refs.py @@ -19,12 +19,14 @@ _REF_PREFIX = "agref1:" _FIND_CURSOR_PREFIX = "agcur1:" -MAX_RECORD_REF_CHARS = 8192 +MAX_RECORD_REF_CHARS = 48 * 1024 """Maximum opaque record-ref length accepted at MCP boundaries. -A common Linux ``PATH_MAX`` path expands to less than 5.5 KiB in base64url; -8 KiB leaves room for the versioned JSON coordinates while bounding decode and -audit work on untrusted input. +Linux ``PATH_MAX`` includes the trailing NUL, leaving at most 4,095 path bytes. +JSON can expand each byte to a six-byte ``\\u00xx`` escape, and base64url then +expands by four thirds: ``4,095 * 6 * 4 / 3 = 32,760`` characters. The 48 KiB +ceiling leaves more than 16 KiB for the versioned envelope while still bounding +decode and audit work on untrusted input. """ diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 81ed63bc7..af15fd4ea 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -105,7 +105,7 @@ def test_export_mcp_docs_define_bounded_inline_contract() -> None: "defaults to false", "`include_bodies=true`", "400 KiB", - "8,192 characters", + "49,152 characters", "one `TextContent` artifact", "structured metadata", "local destination", @@ -135,7 +135,7 @@ def test_export_docs_shim_registers_bounded_public_signature() -> None: assert tuple(parameters) == ("refs", "format", "selection", "include_bodies") assert refs_schema["type"] == "array" assert refs_schema["items"] == { - "maxLength": 8192, + "maxLength": 48 * 1024, "minLength": 1, "type": "string", } From dd0a218d9e04df379e7e823bbd5f8171df8527e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:36:22 -0500 Subject: [PATCH 24/71] agentgrep(fix[mcp]): Reject tilde users why: A forged ref could invoke operating-system user lookup and leak its failure through inspect and export error boundaries. what: - Accept only the canonical tilde and tilde-slash forms. - Reject other leading-tilde paths before user lookup or discovery. - Prove both MCP consumers keep diagnostics private. --- src/agentgrep/mcp/refs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/agentgrep/mcp/refs.py b/src/agentgrep/mcp/refs.py index 291d3cf91..5fc878e18 100644 --- a/src/agentgrep/mcp/refs.py +++ b/src/agentgrep/mcp/refs.py @@ -143,7 +143,10 @@ def _display_path_to_path(value: object, home: pathlib.Path) -> pathlib.Path: return home if value.startswith("~/"): return home / value[2:] - return pathlib.Path(value).expanduser() + if value.startswith("~"): + msg = "token path has unsupported leading tilde" + raise McpTokenError(msg) + return pathlib.Path(value) def _record_fingerprint(payload: dict[str, object]) -> str: From 5f012675e34928b2bdec280fe07631a54aef88bb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 12 Jul 2026 11:37:24 -0500 Subject: [PATCH 25/71] agentgrep(fix[docs]): Bound inspect refs why: The docs-only inspect signature omitted the runtime opaque-ref ceiling and could publish a schema that accepted invalid requests. what: - Apply the shared maximum length to the documentation shim. - Assert exact docs and runtime ref-schema parity. --- docs/_ext/agentgrep_fastmcp.py | 1 + tests/test_export_docs.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/docs/_ext/agentgrep_fastmcp.py b/docs/_ext/agentgrep_fastmcp.py index 5a4af7654..1f8e35768 100644 --- a/docs/_ext/agentgrep_fastmcp.py +++ b/docs/_ext/agentgrep_fastmcp.py @@ -313,6 +313,7 @@ async def inspect_result( str, Field( min_length=1, + max_length=MAX_RECORD_REF_CHARS, description="Opaque ref from a search or find result.", ), ], diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index af15fd4ea..a11275bf5 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -155,6 +155,20 @@ def test_export_docs_shim_registers_bounded_public_signature() -> None: assert metadata.annotations.openWorldHint is False +async def test_docs_inspect_result_ref_schema_matches_runtime() -> None: + """The docs-only ref bound stays byte-for-byte aligned with the tool.""" + from agentgrep.mcp import build_mcp_server + from docs._ext import agentgrep_fastmcp + + hints = t.get_type_hints(agentgrep_fastmcp.inspect_result, include_extras=True) + docs_schema = TypeAdapter(hints["ref"]).json_schema() + runtime_tool = await build_mcp_server().get_tool("inspect_result") + + assert runtime_tool is not None + assert docs_schema == runtime_tool.parameters["properties"]["ref"] + assert docs_schema["maxLength"] == 48 * 1024 + + def test_documented_markdown_runtime_emits_schema_version_once() -> None: """The documented Markdown format carries its schema contract at runtime.""" from agentgrep.record_export import render_export From eb90e1c82cff5568e8affee537cf60b5f28313ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:04:07 -0500 Subject: [PATCH 26/71] agentgrep(feat[tui]): Export selected with e why: A selected result should be exportable without leaving the content panes or typing a slash command. The shortcut must remain ordinary text in inputs and stay out of unrelated HUD controls. what: - Add a pane-scoped, hidden-footer e binding that reuses the private Markdown export worker. - Expose the shortcut through contextual key help and document it. - Cover selected output, input and completion isolation, grep-log scope, and pump roles. --- CHANGES | 3 +- docs/tui/index.md | 5 ++ src/agentgrep/ui/layouts/hud.py | 15 +++++ tests/test_export_docs.py | 1 + tests/test_record_export.py | 6 +- tests/test_ui_export.py | 102 ++++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 7e2fc5f4e..adbc34ee7 100644 --- a/CHANGES +++ b/CHANGES @@ -79,7 +79,8 @@ agentgrep can now turn selected search records into deterministic NDJSON or human-readable Markdown without changing the underlying histories. The CLI exports matching records to standard output or a chosen file, the HUD exports one selected record or its observed thread, and MCP returns a bounded inline -artifact for existing search refs. +artifact for existing search refs. In the HUD, `e` exports the selected record +from the results list or detail pane and remains ordinary text in inputs. Machine clients must opt in before an MCP export includes prompt or history bodies, and file output refuses accidental replacement. See {ref}`the export diff --git a/docs/tui/index.md b/docs/tui/index.md index 00154100a..1e85c5960 100644 --- a/docs/tui/index.md +++ b/docs/tui/index.md @@ -299,6 +299,11 @@ The HUD offers two pi-like slash commands: the current result set after the in-list filter. A record without a canonical thread handle cannot be exported as a thread. +Press `e` with the results list or detail pane focused to export the selected +record to the private Markdown destination. Use `/export PATH` when an explicit +destination is needed. The contextual `/keys` panel lists the shortcut without +adding it to the compact footer. + Without `PATH`, both commands write a collision-free Markdown artifact to agentgrep's private export directory. Its root follows `XDG_DATA_HOME`; when set, artifacts go under `$XDG_DATA_HOME/agentgrep/exports`, and otherwise the diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index 1c62e7bfe..fed2aefd6 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -148,6 +148,7 @@ class HudLayout(_HudSearchBase): BINDINGS: t.ClassVar[list[BindingType]] = [ ("tab", "app.focus_next", "Switch focus"), ("q", "confirm_quit", "Quit"), + Binding("e", "export_selected", "Export selected", show=False), ("escape", "stop_search", "Stop search"), ("ctrl+backslash", "toggle_detail_progress", "Detail"), COPY_SELECTION_BINDING, @@ -1057,6 +1058,20 @@ def action_focus_pane_down(self) -> None: elif focused_id == "results" and self._stacked: self._focus_detail() + @_runtime.pump_only + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + """Expose record export only in a content pane with a live selection.""" + if action == "export_selected": + return self.focused in (self._results, self._detail_scroll) and ( + self._selected_export_record() is not None + ) + return super().check_action(action, parameters) + + @_runtime.pump_only + def action_export_selected(self) -> None: + """Export the selected record to the private default destination.""" + self.request_export("", selection="records") + @_runtime.pump_only def request_export(self, destination: str, *, selection: _ExportSelection) -> bool: """Accept one selected-record or observed-thread export request. diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index a11275bf5..4d759fb84 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -74,6 +74,7 @@ def test_export_tui_docs_define_private_off_pump_workflow() -> None: required = ( "`/export [PATH]`", "`/export-thread [PATH]`", + "Press `e`", "selected record", "observed thread", "current result set", diff --git a/tests/test_record_export.py b/tests/test_record_export.py index e57077713..cc2e4403c 100644 --- a/tests/test_record_export.py +++ b/tests/test_record_export.py @@ -106,9 +106,9 @@ def test_render_export_covers_cardinality_format_and_body_permutations( assert artifact.text.startswith("# agentgrep record export\n") -@pytest.mark.parametrize("selection", ("records", "thread")) -@pytest.mark.parametrize("include_bodies", (False, True), ids=("metadata", "bodies")) -@pytest.mark.parametrize("record_count", (0, 1, 3), ids=("zero", "one", "many")) +@pytest.mark.parametrize("selection", ["records", "thread"]) +@pytest.mark.parametrize("include_bodies", [False, True], ids=("metadata", "bodies")) +@pytest.mark.parametrize("record_count", [0, 1, 3], ids=("zero", "one", "many")) def test_markdown_schema_version_is_once_per_artifact_permutation( selection: record_export.ExportSelection, include_bodies: bool, diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 1dd0e1ddb..9d4b78067 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -10,6 +10,7 @@ import typing as t import pytest +from textual.widgets import HelpPanel from agentgrep import identity, record_export from agentgrep.progress import SearchRequestedPayload @@ -124,6 +125,107 @@ def _change_results(screen: t.Any, change: str, replacement: SearchRecord) -> No screen._start_search_worker(screen._build_search_query("replacement")) +@pytest.mark.parametrize("pane", ["_results", "_detail_scroll"], ids=("results", "detail")) +@pytest.mark.slow +async def test_export_shortcut_writes_selected_record_and_appears_in_keys( + pane: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Plain ``e`` exports from either content pane and appears in key help.""" + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + app = _build_empty_ui_app(tmp_path, monkeypatch) + records = ( + _record(tmp_path, "first body", ordinal=1), + _record(tmp_path, "selected body", ordinal=2), + ) + export_dir = tmp_path / "data" / "agentgrep" / "exports" + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, records, selected=1) + + app.screen._search_input.value = "/keys" + app.screen._search_input.focus() + await pilot.press("enter") + getattr(app.screen, pane).focus() + await pilot.pause() + + binding = app.screen.active_bindings["e"].binding + assert len(app.screen.query(HelpPanel)) == 1 + assert binding.description == "Export selected" + assert binding.show is False + + await pilot.press("e") + await _wait_for(lambda: bool(list(export_dir.glob("*.md")))) + + exported = next(export_dir.glob("*.md")).read_text(encoding="utf-8") + assert "selected body" in exported + assert "first body" not in exported + + +@pytest.mark.parametrize( + "input_attr", + ["_search_input", "_filter_input"], + ids=("search", "filter"), +) +@pytest.mark.slow +async def test_export_shortcut_remains_literal_in_inputs( + input_attr: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Editable inputs consume ``e`` without starting an export.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + requests: list[tuple[str, str]] = [] + monkeypatch.setattr( + app.screen, + "request_export", + lambda destination, *, selection: requests.append((destination, selection)), + ) + input_widget = getattr(app.screen, input_attr) + input_widget.focus() + + await pilot.press("e") + await pilot.pause() + + assert input_widget.value == "e" + assert requests == [] + + +@pytest.mark.slow +async def test_export_shortcut_is_inert_in_completion_dropdown( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Completion focus never turns a printable choice key into export.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "selected body", ordinal=1) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (record,)) + requests: list[tuple[str, str]] = [] + monkeypatch.setattr( + app.screen, + "request_export", + lambda destination, *, selection: requests.append((destination, selection)), + ) + + app.screen._search_input.value = "scope:" + app.screen._search_input.focus() + await pilot.pause() + await pilot.press("down") + await pilot.pause() + assert app.focused is app.screen._enum_dropdown + + await pilot.press("e") + await pilot.pause() + + assert "e" not in app.screen.active_bindings + assert requests == [] + + @pytest.mark.slow async def test_export_commands_accept_paths_but_legacy_args_stay_searches( tmp_path: pathlib.Path, From 6c0fc2320b67928af6e4e33cea599229d5f8deae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:04:55 -0500 Subject: [PATCH 27/71] agentgrep(fix[tui]): Require pane selection why: The export shortcut must represent a live selection in the focused pane. Falling back to stale detail state or row zero could export a record the user had not selected. what: - Gate the shortcut on an in-range results highlight or a displayed detail record still present in the result list. - Cover missing and stale pane selections with mounted Pilot tests. --- src/agentgrep/ui/layouts/hud.py | 20 +++++++++++++++--- tests/test_ui_export.py | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index fed2aefd6..40ac70060 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -1062,9 +1062,7 @@ def action_focus_pane_down(self) -> None: def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: """Expose record export only in a content pane with a live selection.""" if action == "export_selected": - return self.focused in (self._results, self._detail_scroll) and ( - self._selected_export_record() is not None - ) + return self._selected_export_shortcut_record() is not None return super().check_action(action, parameters) @_runtime.pump_only @@ -1128,6 +1126,22 @@ def _selected_export_record(self) -> SearchRecord | None: return self._current_detail_record return self.filtered_records[0] if self.filtered_records else None + def _selected_export_shortcut_record(self) -> SearchRecord | None: + """Return the live selection owned by the focused content pane.""" + if self.focused is self._results and self._results is not None: + highlighted = t.cast("int | None", getattr(self._results, "highlighted", None)) + if highlighted is not None and 0 <= highlighted < len(self.filtered_records): + return self.filtered_records[highlighted] + elif self.focused is self._detail_scroll: + current = self._current_detail_record + if ( + current is not None + and self._results is not None + and self._results.contains_record(current) + ): + return current + return None + def _export_request_is_live( self, generation: int, diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 9d4b78067..a408ea5c8 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -205,6 +205,7 @@ async def test_export_shortcut_is_inert_in_completion_dropdown( async with app.run_test(size=(120, 30)) as pilot: await pilot.pause() await _load_records(app.screen, (record,)) + await pilot.pause() requests: list[tuple[str, str]] = [] monkeypatch.setattr( app.screen, @@ -226,6 +227,41 @@ async def test_export_shortcut_is_inert_in_completion_dropdown( assert requests == [] +@pytest.mark.parametrize("pane", ["_results", "_detail_scroll"], ids=("results", "detail")) +@pytest.mark.slow +async def test_export_shortcut_requires_live_pane_selection( + pane: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shortcut is absent when its focused pane has no live selection.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "live body", ordinal=1) + stale = _record(tmp_path, "stale body", ordinal=2) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + await _load_records(app.screen, (record,)) + await pilot.pause() + requests: list[tuple[str, str]] = [] + monkeypatch.setattr( + app.screen, + "request_export", + lambda destination, *, selection: requests.append((destination, selection)), + ) + if pane == "_results": + app.screen._results.highlighted = None + else: + app.screen._current_detail_record = stale + getattr(app.screen, pane).focus() + await pilot.pause() + + assert "e" not in app.screen.active_bindings + await pilot.press("e") + await pilot.pause() + + assert requests == [] + + @pytest.mark.slow async def test_export_commands_accept_paths_but_legacy_args_stay_searches( tmp_path: pathlib.Path, From 96e02f115f8630d7f474ad13bf5bf6e73606c12c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:06:14 -0500 Subject: [PATCH 28/71] agentgrep(fix[tui]): Export focused pane row why: Results and detail selections can diverge while filtering or browsing. The shortcut must export the record shown by the pane that owns focus. what: - Pass the pane-specific live record into the existing export request path. - Prove results exports its highlight while detail exports its displayed record. --- src/agentgrep/ui/layouts/hud.py | 16 +++++++++++++--- tests/test_ui_export.py | 11 ++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index 40ac70060..04e68d393 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -1068,10 +1068,18 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No @_runtime.pump_only def action_export_selected(self) -> None: """Export the selected record to the private default destination.""" - self.request_export("", selection="records") + selected = self._selected_export_shortcut_record() + if selected is not None: + self.request_export("", selection="records", selected_record=selected) @_runtime.pump_only - def request_export(self, destination: str, *, selection: _ExportSelection) -> bool: + def request_export( + self, + destination: str, + *, + selection: _ExportSelection, + selected_record: SearchRecord | None = None, + ) -> bool: """Accept one selected-record or observed-thread export request. Only bounded state capture happens synchronously. Thread exports copy @@ -1085,7 +1093,9 @@ def request_export(self, destination: str, *, selection: _ExportSelection) -> bo severity="warning", ) return False - selected = self._selected_export_record() + selected = ( + selected_record if selected_record is not None else self._selected_export_record() + ) if selected is None: self.notify( "Select a record before exporting", diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index a408ea5c8..5d38058f6 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -142,7 +142,10 @@ async def test_export_shortcut_writes_selected_record_and_appears_in_keys( export_dir = tmp_path / "data" / "agentgrep" / "exports" async with app.run_test(size=(120, 30)) as pilot: await pilot.pause() - await _load_records(app.screen, records, selected=1) + await _load_records(app.screen, records, selected=0) + await pilot.pause() + app.screen.show_detail(records[1]) + await pilot.pause() app.screen._search_input.value = "/keys" app.screen._search_input.focus() @@ -159,8 +162,10 @@ async def test_export_shortcut_writes_selected_record_and_appears_in_keys( await _wait_for(lambda: bool(list(export_dir.glob("*.md")))) exported = next(export_dir.glob("*.md")).read_text(encoding="utf-8") - assert "selected body" in exported - assert "first body" not in exported + expected = "first body" if pane == "_results" else "selected body" + unexpected = "selected body" if pane == "_results" else "first body" + assert expected in exported + assert unexpected not in exported @pytest.mark.parametrize( From c22040bceec8c0b56d2e0299abe0cd9199dbd2c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:07:05 -0500 Subject: [PATCH 29/71] agentgrep(feat[tui]): Add export preferences why: The TUI export flow needs bounded private preference storage and a deterministic filename compiler before Textual can safely consume either value. what: - Add XDG-aware paths, exact-schema JSON loading, and durable atomic saves with private modes. - Add a bounded Unicode filename-template compiler with path-safe validation. - Cover defaults, invalid input, short writes, cleanup, and permissions. --- src/agentgrep/ui/_export_preferences.py | 382 +++++++++++++++++++++++ tests/test_ui_export_preferences.py | 389 ++++++++++++++++++++++++ 2 files changed, 771 insertions(+) create mode 100644 src/agentgrep/ui/_export_preferences.py create mode 100644 tests/test_ui_export_preferences.py diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py new file mode 100644 index 000000000..a95e5c06a --- /dev/null +++ b/src/agentgrep/ui/_export_preferences.py @@ -0,0 +1,382 @@ +"""Bounded TUI export preferences and filename compilation. + +This module is deliberately Textual-free. Callers offload its filesystem I/O +and pass the resulting immutable values into the TUI. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import datetime +import json +import ntpath +import os +import pathlib +import tempfile +import typing as t +import unicodedata + +DEFAULT_FILENAME_TEMPLATE = "{date} {time} - {title}.md" +MAX_PREFERENCES_BYTES = 16 * 1024 +MAX_TEMPLATE_CHARS = 256 +MAX_FILENAME_BYTES = 180 + +_PREFERENCES_WARNING = "Export preferences could not be read" +_PREFERENCES_SAVE_ERROR = "Export preferences could not be saved" +_FILENAME_ERROR = "Export filename is invalid" +_SCHEMA_KEYS = frozenset({"version", "directory", "filename_template"}) + +__all__ = [ + "DEFAULT_FILENAME_TEMPLATE", + "MAX_FILENAME_BYTES", + "MAX_PREFERENCES_BYTES", + "MAX_TEMPLATE_CHARS", + "ExportPreferences", + "ExportPreferencesError", + "ExportPreferencesLoad", + "default_export_directory", + "export_preferences_path", + "load_export_preferences", + "render_export_filename", + "resolve_export_directory", + "save_export_preferences", +] + + +@dataclasses.dataclass(frozen=True, slots=True) +class ExportPreferences: + """Persisted values for the TUI export dialog.""" + + directory: str + filename_template: str = DEFAULT_FILENAME_TEMPLATE + + +@dataclasses.dataclass(frozen=True, slots=True) +class ExportPreferencesLoad: + """Loaded preferences plus an optional path-free warning.""" + + preferences: ExportPreferences + warning: str | None = None + + +class ExportPreferencesError(Exception): + """A path-free preference or filename failure.""" + + +def _xdg_path(variable: str, fallback: pathlib.Path) -> pathlib.Path: + """Return a configured non-empty XDG root or ``fallback``.""" + configured = os.environ.get(variable) + return pathlib.Path(configured) if configured else fallback + + +def export_preferences_path(home: pathlib.Path) -> pathlib.Path: + """Return the TUI export-preference file path. + + Parameters + ---------- + home : pathlib.Path + Current user's home directory used for the XDG fallback. + + Returns + ------- + pathlib.Path + ``agentgrep/tui-export.json`` below the active configuration root. + """ + root = _xdg_path("XDG_CONFIG_HOME", home / ".config") + return root / "agentgrep" / "tui-export.json" + + +def default_export_directory(home: pathlib.Path) -> pathlib.Path: + """Return the default private TUI export directory. + + Parameters + ---------- + home : pathlib.Path + Current user's home directory used for the XDG fallback. + + Returns + ------- + pathlib.Path + ``agentgrep/exports`` below the active data root. + """ + root = _xdg_path("XDG_DATA_HOME", home / ".local" / "share") + return root / "agentgrep" / "exports" + + +def resolve_export_directory(value: str, home: pathlib.Path) -> pathlib.Path: + """Resolve only the current-user tilde spelling in ``value``. + + Parameters + ---------- + value : str + Literal directory value from the export dialog. + home : pathlib.Path + Current user's home directory. + + Returns + ------- + pathlib.Path + The supplied path, with bare ``~`` or ``~/`` expanded against ``home``. + + Raises + ------ + ExportPreferencesError + If an other-user tilde spelling is supplied. + """ + if value == "~" or value == f"~{os.sep}": + return home + current_home_prefix = f"~{os.sep}" + if value.startswith(current_home_prefix): + return home / value[len(current_home_prefix) :] + if value.startswith("~"): + raise ExportPreferencesError(_FILENAME_ERROR) + return pathlib.Path(value) + + +def _slug(value: str) -> str: + """Return a bounded NFKC/casefolded Unicode-alphanumeric slug.""" + normalized = unicodedata.normalize("NFKC", value[:MAX_TEMPLATE_CHARS]).casefold() + pieces: list[str] = [] + pending_separator = False + for character in normalized: + if character.isalnum(): + if pending_separator and pieces: + pieces.append("-") + pieces.append(character) + pending_separator = False + else: + pending_separator = True + return "".join(pieces) + + +def _validate_filename(filename: str) -> None: + """Reject an unsafe or unreviewable compiled filename.""" + if "{" in filename or "}" in filename: + raise ExportPreferencesError(_FILENAME_ERROR) + if any(unicodedata.category(character) in {"Cc", "Cs"} for character in filename): + raise ExportPreferencesError(_FILENAME_ERROR) + if "/" in filename or "\\" in filename: + raise ExportPreferencesError(_FILENAME_ERROR) + if filename in {".", ".."} or filename.endswith((" ", ".")): + raise ExportPreferencesError(_FILENAME_ERROR) + if not filename.endswith(".md") or not filename.removesuffix(".md"): + raise ExportPreferencesError(_FILENAME_ERROR) + if ntpath.isreserved(filename): + raise ExportPreferencesError(_FILENAME_ERROR) + try: + encoded = filename.encode("utf-8") + except UnicodeEncodeError: + raise ExportPreferencesError(_FILENAME_ERROR) from None + if len(encoded) > MAX_FILENAME_BYTES: + raise ExportPreferencesError(_FILENAME_ERROR) + + +def render_export_filename( + template: str, + title: str, + fallback_title: str, + timestamp: datetime.datetime, +) -> str: + """Compile one reviewed Markdown filename from the tiny token grammar. + + Parameters + ---------- + template : str + Template containing only the ``date``, ``time``, and ``title`` tokens. + title : str + Record title used to build the filename slug. + fallback_title : str + Non-sensitive fallback used when ``title`` produces an empty slug. + timestamp : datetime.datetime + Frozen local timestamp captured when the dialog opened. + + Returns + ------- + str + Validated Markdown basename. + + Raises + ------ + ExportPreferencesError + If the template or compiled basename is unsafe or outside its bounds. + """ + if not isinstance(template, str) or len(template) > MAX_TEMPLATE_CHARS: + raise ExportPreferencesError(_FILENAME_ERROR) + slug = _slug(title) or _slug(fallback_title) + if not slug: + raise ExportPreferencesError(_FILENAME_ERROR) + filename = template + substitutions = { + "date": timestamp.strftime("%Y-%m-%d"), + "time": timestamp.strftime("%H-%M-%S"), + "title": slug, + } + for token, value in substitutions.items(): + filename = filename.replace(f"{{{token}}}", value) + _validate_filename(filename) + return filename + + +def _default_preferences(home: pathlib.Path) -> ExportPreferences: + """Return first-run preferences for ``home``.""" + return ExportPreferences(directory=str(default_export_directory(home))) + + +def _unique_object(pairs: list[tuple[str, t.Any]]) -> dict[str, t.Any]: + """Build one JSON object while rejecting duplicate keys.""" + result: dict[str, t.Any] = {} + for key, value in pairs: + if key in result: + raise ValueError + result[key] = value + return result + + +def _parse_preferences(payload: bytes) -> ExportPreferences: + """Parse an exact-version preference payload.""" + data = json.loads(payload.decode("utf-8"), object_pairs_hook=_unique_object) + if not isinstance(data, dict) or frozenset(data) != _SCHEMA_KEYS: + raise ValueError + version = data["version"] + directory = data["directory"] + filename_template = data["filename_template"] + if type(version) is not int or version != 1: + raise ValueError + if not isinstance(directory, str) or not isinstance(filename_template, str): + raise TypeError + render_export_filename( + filename_template, + title="Title", + fallback_title="record", + timestamp=datetime.datetime(2000, 1, 1), + ) + return ExportPreferences(directory=directory, filename_template=filename_template) + + +def _read_preferences(path: pathlib.Path) -> bytes: + """Read one payload without crossing the preference byte limit.""" + with path.open("rb") as handle: + if os.fstat(handle.fileno()).st_size > MAX_PREFERENCES_BYTES: + raise ValueError + return handle.read(MAX_PREFERENCES_BYTES) + + +def load_export_preferences(home: pathlib.Path) -> ExportPreferencesLoad: + """Load a bounded exact-schema preference file. + + Parameters + ---------- + home : pathlib.Path + Current user's home directory used by path defaults. + + Returns + ------- + ExportPreferencesLoad + Stored preferences, or defaults with a path-free warning on invalid I/O + or content. A missing file returns defaults without a warning. + """ + defaults = _default_preferences(home) + path = export_preferences_path(home) + try: + preferences = _parse_preferences(_read_preferences(path)) + except FileNotFoundError: + return ExportPreferencesLoad(defaults) + except ExportPreferencesError, OSError, UnicodeError, ValueError, TypeError: + return ExportPreferencesLoad(defaults, _PREFERENCES_WARNING) + return ExportPreferencesLoad(preferences) + + +def _write_all(file_descriptor: int, payload: bytes) -> None: + """Write every byte, retrying positive short writes.""" + view = memoryview(payload) + offset = 0 + while offset < len(view): + written = os.write(file_descriptor, view[offset:]) + if written <= 0: + raise OSError + offset += written + + +def _serialize_preferences(preferences: ExportPreferences) -> bytes: + """Validate and serialize one exact-schema preference payload.""" + if not isinstance(preferences.directory, str) or not isinstance( + preferences.filename_template, + str, + ): + raise ExportPreferencesError(_PREFERENCES_SAVE_ERROR) + render_export_filename( + preferences.filename_template, + title="Title", + fallback_title="record", + timestamp=datetime.datetime(2000, 1, 1), + ) + payload = json.dumps( + { + "version": 1, + "directory": preferences.directory, + "filename_template": preferences.filename_template, + }, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + if len(payload) > MAX_PREFERENCES_BYTES: + raise ExportPreferencesError(_PREFERENCES_SAVE_ERROR) + return payload + + +def save_export_preferences(home: pathlib.Path, preferences: ExportPreferences) -> None: + """Atomically save private TUI export preferences. + + Parameters + ---------- + home : pathlib.Path + Current user's home directory used by the config-path fallback. + preferences : ExportPreferences + Reviewed directory and filename template to persist. + + Raises + ------ + ExportPreferencesError + If validation, creation, writing, synchronization, or installation fails. + """ + try: + payload = _serialize_preferences(preferences) + destination = export_preferences_path(home) + config_directory = destination.parent + config_directory.mkdir(mode=0o700, exist_ok=True) + config_directory.chmod(0o700) + file_descriptor, temporary_name = tempfile.mkstemp( + dir=config_directory, + prefix=".tui-export-", + suffix=".tmp", + ) + except ExportPreferencesError, OSError, UnicodeError, ValueError, TypeError: + raise ExportPreferencesError(_PREFERENCES_SAVE_ERROR) from None + + temporary = pathlib.Path(temporary_name) + installed = False + try: + try: + os.fchmod(file_descriptor, 0o600) + _write_all(file_descriptor, payload) + os.fsync(file_descriptor) + finally: + with contextlib.suppress(OSError): + os.close(file_descriptor) + os.replace(temporary, destination) # noqa: PTH105 -- required atomic primitive + installed = True + directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) + directory_flags |= getattr(os, "O_CLOEXEC", 0) + directory_fd = os.open(config_directory, directory_flags) + try: + os.fsync(directory_fd) + finally: + with contextlib.suppress(OSError): + os.close(directory_fd) + except OSError: + raise ExportPreferencesError(_PREFERENCES_SAVE_ERROR) from None + finally: + if not installed: + with contextlib.suppress(OSError): + temporary.unlink() diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py new file mode 100644 index 000000000..bf8477175 --- /dev/null +++ b/tests/test_ui_export_preferences.py @@ -0,0 +1,389 @@ +"""Tests for bounded TUI export preferences and filename compilation.""" + +from __future__ import annotations + +import datetime +import json +import os +import pathlib +import stat +import typing as t + +import pytest + +from agentgrep.ui import _export_preferences as export_preferences +from agentgrep.ui._export_preferences import ( + DEFAULT_FILENAME_TEMPLATE, + MAX_FILENAME_BYTES, + MAX_PREFERENCES_BYTES, + MAX_TEMPLATE_CHARS, + ExportPreferences, + ExportPreferencesError, + default_export_directory, + export_preferences_path, + load_export_preferences, + render_export_filename, + resolve_export_directory, + save_export_preferences, +) + +DEFAULT_TEMPLATE = "{date} {time} - {title}.md" + + +def test_export_preferences_path_follows_xdg_config_home( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The TUI-private file follows the configured XDG config root.""" + config_home = tmp_path / "config" + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + + assert export_preferences_path(tmp_path / "home") == ( + config_home / "agentgrep" / "tui-export.json" + ) + + +def test_export_preferences_path_falls_back_under_home( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The config path uses the standard home fallback without XDG config.""" + home = tmp_path / "home" + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + + assert export_preferences_path(home) == (home / ".config" / "agentgrep" / "tui-export.json") + + +def test_default_export_directory_follows_xdg_data_home( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The default destination follows the configured XDG data root.""" + data_home = tmp_path / "data" + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + + assert default_export_directory(tmp_path / "home") == (data_home / "agentgrep" / "exports") + + +def test_default_export_directory_falls_back_under_home( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The default destination uses the standard home fallback.""" + home = tmp_path / "home" + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + + assert default_export_directory(home) == (home / ".local" / "share" / "agentgrep" / "exports") + + +@pytest.mark.parametrize( + ("value", "suffix"), + ( + ("~", ()), + ("~/", ()), + ("~/Exports", ("Exports",)), + ("~/Exports/agentgrep", ("Exports", "agentgrep")), + ), +) +def test_resolve_export_directory_expands_only_current_home( + value: str, + suffix: tuple[str, ...], + tmp_path: pathlib.Path, +) -> None: + """A bare or slash-suffixed tilde expands against the supplied home.""" + home = tmp_path / "home" + + assert resolve_export_directory(value, home) == home.joinpath(*suffix) + + +def test_resolve_export_directory_preserves_non_tilde_path( + tmp_path: pathlib.Path, +) -> None: + """Absolute and relative paths do not receive implicit expansion.""" + absolute = tmp_path / "Exports" + + assert resolve_export_directory(str(absolute), tmp_path / "home") == absolute + assert resolve_export_directory("relative/exports", tmp_path / "home") == pathlib.Path( + "relative/exports" + ) + + +def test_resolve_export_directory_rejects_other_users( + tmp_path: pathlib.Path, +) -> None: + """Other-user tilde syntax is never delegated to account lookup.""" + with pytest.raises(ExportPreferencesError): + resolve_export_directory("~other/Exports", tmp_path / "home") + + +def test_default_export_filename_is_frozen_local_ascii() -> None: + """The default template compiles to the reviewed local-time basename.""" + when = datetime.datetime(2026, 7, 14, 9, 8, 7).astimezone() + assert ( + render_export_filename( + DEFAULT_TEMPLATE, + title="Refactor: Planner / Review", + fallback_title="codex-prompt", + timestamp=when, + ) + == "2026-07-14 09-08-07 - refactor-planner-review.md" + ) + + +@pytest.mark.parametrize( + "template", + ( + "{unknown}.md", + "../{title}.md", + "{title}/body.md", + "{title}", + ".md", + "CON.md", + ), +) +def test_export_filename_rejects_unreviewable_names(template: str) -> None: + """Unsafe, unsupported, or extensionless output names are rejected.""" + with pytest.raises(ExportPreferencesError): + render_export_filename( + template, + title="Title", + fallback_title="codex-prompt", + timestamp=datetime.datetime(2026, 7, 14).astimezone(), + ) + + +@pytest.mark.parametrize( + "template", + ( + "{{title}}.md", + "{title}.md ", + "{title}.md.", + "{title}\n.md", + "\ud800.md", + ), +) +def test_export_filename_rejects_ambiguous_or_non_scalar_output(template: str) -> None: + """Braces, trailing ambiguity, controls, and surrogates are rejected.""" + with pytest.raises(ExportPreferencesError): + render_export_filename( + template, + title="Title", + fallback_title="codex-prompt", + timestamp=datetime.datetime(2026, 7, 14).astimezone(), + ) + + +def test_export_filename_normalizes_unicode_and_uses_sanitized_fallback() -> None: + """Unicode letters survive while separators collapse and empty titles fall back.""" + when = datetime.datetime(2026, 7, 14).astimezone() + + assert ( + render_export_filename( + "{title}.md", + title=" Crème 🚀 東京 ", + fallback_title="codex-prompt", + timestamp=when, + ) + == "crème-東京.md" + ) + assert ( + render_export_filename( + "{title}.md", + title="///", + fallback_title="Codex Prompt", + timestamp=when, + ) + == "codex-prompt.md" + ) + + +def test_export_filename_slices_raw_title_before_nfkc_normalization() -> None: + """Normalization cannot pull title content from beyond the raw bound.""" + title = "-" * 255 + "Ⅳ" + "B" + + assert ( + render_export_filename( + "{title}.md", + title=title, + fallback_title="codex-prompt", + timestamp=datetime.datetime(2026, 7, 14).astimezone(), + ) + == "iv.md" + ) + + +def test_export_filename_rejects_template_and_utf8_filename_over_bounds() -> None: + """Both editable templates and compiled UTF-8 names have fixed bounds.""" + with pytest.raises(ExportPreferencesError): + render_export_filename( + "x" * (MAX_TEMPLATE_CHARS + 1) + ".md", + title="Title", + fallback_title="codex-prompt", + timestamp=datetime.datetime(2026, 7, 14).astimezone(), + ) + + with pytest.raises(ExportPreferencesError): + render_export_filename( + "{title}.md", + title="é" * MAX_FILENAME_BYTES, + fallback_title="codex-prompt", + timestamp=datetime.datetime(2026, 7, 14).astimezone(), + ) + + +def test_missing_export_preferences_return_defaults_without_warning( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An absent private config is a normal first-run state.""" + config_home = tmp_path / "config" + data_home = tmp_path / "data" + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + + loaded = load_export_preferences(tmp_path / "home") + + assert loaded.preferences == ExportPreferences( + directory=str(data_home / "agentgrep" / "exports"), + filename_template=DEFAULT_FILENAME_TEMPLATE, + ) + assert loaded.warning is None + + +@pytest.mark.parametrize( + "payload", + ( + b"{", + b" " * (MAX_PREFERENCES_BYTES + 1), + b'{"version":2,"directory":"~/Exports","filename_template":"{title}.md"}', + b'{"version":true,"directory":"~/Exports","filename_template":"{title}.md"}', + b'{"version":1,"directory":[],"filename_template":"{title}.md"}', + b'{"version":1,"directory":"~/Exports","filename_template":2}', + b'{"version":1,"directory":"~/Exports","filename_template":"{title}.md","extra":1}', + b'{"version":1,"version":1,"directory":"~/Exports","filename_template":"{title}.md"}', + ), +) +def test_invalid_export_preferences_return_defaults_with_warning( + payload: bytes, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Malformed, oversized, or non-exact schemas degrade to safe defaults.""" + config_home = tmp_path / "config" + data_home = tmp_path / "data" + config_path = config_home / "agentgrep" / "tui-export.json" + config_path.parent.mkdir(parents=True) + config_path.write_bytes(payload) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + + loaded = load_export_preferences(tmp_path / "home") + + assert loaded.preferences == ExportPreferences( + directory=str(data_home / "agentgrep" / "exports") + ) + assert loaded.warning == "Export preferences could not be read" + + +def test_export_preferences_round_trip_unicode_with_private_modes( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Saving preserves Unicode and fixes app-owned config permissions.""" + config_home = tmp_path / "config" + config_home.mkdir(mode=0o755) + config_home.chmod(0o755) + app_config = config_home / "agentgrep" + app_config.mkdir(mode=0o755) + app_config.chmod(0o755) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + preferences = ExportPreferences( + directory="~/Éxports/東京", + filename_template="{date} — {title}.md", + ) + + save_export_preferences(tmp_path / "home", preferences) + + config_path = export_preferences_path(tmp_path / "home") + assert load_export_preferences(tmp_path / "home").preferences == preferences + assert stat.S_IMODE(config_path.stat().st_mode) == 0o600 + assert stat.S_IMODE(app_config.stat().st_mode) == 0o700 + assert stat.S_IMODE(config_home.stat().st_mode) == 0o755 + assert json.loads(config_path.read_text(encoding="utf-8")) == { + "version": 1, + "directory": "~/Éxports/東京", + "filename_template": "{date} — {title}.md", + } + + +def test_save_export_preferences_retries_short_writes( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A successful atomic save drains every short write.""" + config_home = tmp_path / "config" + config_home.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + real_write = os.write + write_sizes: list[int] = [] + + def short_write(fd: int, data: bytes | bytearray | memoryview) -> int: + chunk = data[:7] + write_sizes.append(len(chunk)) + return real_write(fd, chunk) + + monkeypatch.setattr(export_preferences.os, "write", short_write) + preferences = ExportPreferences( + directory="~/" + "É" * 100, + filename_template=DEFAULT_TEMPLATE, + ) + + save_export_preferences(tmp_path / "home", preferences) + + assert len(write_sizes) > 1 + assert load_export_preferences(tmp_path / "home").preferences == preferences + + +def test_save_export_preferences_cleans_temp_after_write_failure( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed atomic write leaves no temporary or destination file.""" + config_home = tmp_path / "config" + config_home.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + + def fail_write(_fd: int, _data: bytes | bytearray | memoryview) -> t.NoReturn: + raise OSError + + monkeypatch.setattr(export_preferences.os, "write", fail_write) + + with pytest.raises(ExportPreferencesError) as raised: + save_export_preferences( + tmp_path / "home", + ExportPreferences(directory="~/Exports"), + ) + + app_config = config_home / "agentgrep" + assert list(app_config.iterdir()) == [] + assert str(config_home) not in str(raised.value) + + +def test_save_export_preferences_never_chmods_selected_directory( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Saving config changes no permissions on the user-selected destination.""" + config_home = tmp_path / "config" + config_home.mkdir() + selected = tmp_path / "Selected" + selected.mkdir(mode=0o750) + selected.chmod(0o750) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + + save_export_preferences( + tmp_path / "home", + ExportPreferences(directory=str(selected)), + ) + + assert stat.S_IMODE(selected.stat().st_mode) == 0o750 From f10b1ed488d880ba2cf1d817635344d7cb51f79a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:12:50 -0500 Subject: [PATCH 30/71] agentgrep(fix[tui]): Contain tilde paths why: Repeated separators after ~/ produced an absolute pathlib suffix that escaped the supplied home, and invalid directories reported a filename error. what: - Keep repeated-separator tilde paths relative to the supplied home. - Give invalid directory syntax a distinct path-free error. - Cover ~//Exports and ~/// as current-home paths. --- src/agentgrep/ui/_export_preferences.py | 8 +++++--- tests/test_ui_export_preferences.py | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index a95e5c06a..9e7ecee1e 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -24,6 +24,7 @@ _PREFERENCES_WARNING = "Export preferences could not be read" _PREFERENCES_SAVE_ERROR = "Export preferences could not be saved" +_DIRECTORY_ERROR = "Export directory is invalid" _FILENAME_ERROR = "Export filename is invalid" _SCHEMA_KEYS = frozenset({"version", "directory", "filename_template"}) @@ -124,13 +125,14 @@ def resolve_export_directory(value: str, home: pathlib.Path) -> pathlib.Path: ExportPreferencesError If an other-user tilde spelling is supplied. """ - if value == "~" or value == f"~{os.sep}": + if value == "~": return home current_home_prefix = f"~{os.sep}" if value.startswith(current_home_prefix): - return home / value[len(current_home_prefix) :] + suffix = value[len(current_home_prefix) :].lstrip(os.sep) + return home / suffix if suffix else home if value.startswith("~"): - raise ExportPreferencesError(_FILENAME_ERROR) + raise ExportPreferencesError(_DIRECTORY_ERROR) return pathlib.Path(value) diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index bf8477175..65a7e589a 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -81,6 +81,8 @@ def test_default_export_directory_falls_back_under_home( ( ("~", ()), ("~/", ()), + ("~//Exports", ("Exports",)), + ("~///", ()), ("~/Exports", ("Exports",)), ("~/Exports/agentgrep", ("Exports", "agentgrep")), ), @@ -112,7 +114,10 @@ def test_resolve_export_directory_rejects_other_users( tmp_path: pathlib.Path, ) -> None: """Other-user tilde syntax is never delegated to account lookup.""" - with pytest.raises(ExportPreferencesError): + with pytest.raises( + ExportPreferencesError, + match=r"^Export directory is invalid$", + ): resolve_export_directory("~other/Exports", tmp_path / "home") From 391d19e0ee5e5c17b568817dd2054e873282be2d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:13:46 -0500 Subject: [PATCH 31/71] agentgrep(fix[tui]): Secure config writes why: Path-based config setup followed an existing agentgrep symlink, allowing preference saves to chmod and write an aliased directory. what: - Secure the app-owned config child with descriptor-relative no-follow opens. - Create, install, and clean temp files relative to the secured descriptor. - Cover a symlink alias without mode or content changes. --- src/agentgrep/ui/_export_preferences.py | 108 ++++++++++++++++++------ tests/test_ui_export_preferences.py | 30 +++++++ 2 files changed, 111 insertions(+), 27 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index 9e7ecee1e..68f39e24d 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -13,7 +13,7 @@ import ntpath import os import pathlib -import tempfile +import secrets import typing as t import unicodedata @@ -27,6 +27,8 @@ _DIRECTORY_ERROR = "Export directory is invalid" _FILENAME_ERROR = "Export filename is invalid" _SCHEMA_KEYS = frozenset({"version", "directory", "filename_template"}) +_CONFIG_DIRECTORY_NAME = "agentgrep" +_PREFERENCES_FILENAME = "tui-export.json" __all__ = [ "DEFAULT_FILENAME_TEMPLATE", @@ -300,6 +302,69 @@ def _write_all(file_descriptor: int, payload: bytes) -> None: offset += written +def _close_quietly(file_descriptor: int) -> None: + """Close a cleanup descriptor without replacing the primary failure.""" + with contextlib.suppress(OSError): + os.close(file_descriptor) + + +def _unlink_quietly(directory_fd: int, name: str) -> None: + """Remove a descriptor-relative temporary file when it still exists.""" + with contextlib.suppress(OSError): + os.unlink(name, dir_fd=directory_fd) + + +def _directory_flags(*, no_follow: bool) -> int: + """Return supported directory-open flags for the requested boundary.""" + directory = getattr(os, "O_DIRECTORY", 0) + reject_symlink = getattr(os, "O_NOFOLLOW", 0) + if not directory or (no_follow and not reject_symlink): + raise OSError + flags = os.O_RDONLY | directory | getattr(os, "O_CLOEXEC", 0) + return flags | reject_symlink if no_follow else flags + + +def _open_config_directory(root: pathlib.Path) -> int: + """Open the app-owned config child without following its final name.""" + root_fd = os.open(root, _directory_flags(no_follow=False)) + try: + with contextlib.suppress(FileExistsError): + os.mkdir(_CONFIG_DIRECTORY_NAME, 0o700, dir_fd=root_fd) + config_fd = os.open( + _CONFIG_DIRECTORY_NAME, + _directory_flags(no_follow=True), + dir_fd=root_fd, + ) + finally: + _close_quietly(root_fd) + try: + os.fchmod(config_fd, 0o700) + except OSError: + _close_quietly(config_fd) + raise + return config_fd + + +def _new_temporary(directory_fd: int) -> tuple[str, int]: + """Create one private randomized file relative to a secured directory.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_CLOEXEC", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + for _attempt in range(128): + name = f".tui-export-{secrets.token_hex(12)}.tmp" + try: + file_descriptor = os.open(name, flags, 0o600, dir_fd=directory_fd) + except FileExistsError: + continue + try: + os.fchmod(file_descriptor, 0o600) + except OSError: + _close_quietly(file_descriptor) + _unlink_quietly(directory_fd, name) + raise + return name, file_descriptor + raise OSError + + def _serialize_preferences(preferences: ExportPreferences) -> bytes: """Validate and serialize one exact-schema preference payload.""" if not isinstance(preferences.directory, str) or not isinstance( @@ -345,40 +410,29 @@ def save_export_preferences(home: pathlib.Path, preferences: ExportPreferences) try: payload = _serialize_preferences(preferences) destination = export_preferences_path(home) - config_directory = destination.parent - config_directory.mkdir(mode=0o700, exist_ok=True) - config_directory.chmod(0o700) - file_descriptor, temporary_name = tempfile.mkstemp( - dir=config_directory, - prefix=".tui-export-", - suffix=".tmp", - ) + directory_fd = _open_config_directory(destination.parent.parent) except ExportPreferencesError, OSError, UnicodeError, ValueError, TypeError: raise ExportPreferencesError(_PREFERENCES_SAVE_ERROR) from None - temporary = pathlib.Path(temporary_name) - installed = False + temporary: str | None = None try: + temporary, file_descriptor = _new_temporary(directory_fd) try: - os.fchmod(file_descriptor, 0o600) _write_all(file_descriptor, payload) os.fsync(file_descriptor) finally: - with contextlib.suppress(OSError): - os.close(file_descriptor) - os.replace(temporary, destination) # noqa: PTH105 -- required atomic primitive - installed = True - directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) - directory_flags |= getattr(os, "O_CLOEXEC", 0) - directory_fd = os.open(config_directory, directory_flags) - try: - os.fsync(directory_fd) - finally: - with contextlib.suppress(OSError): - os.close(directory_fd) + _close_quietly(file_descriptor) + os.replace( + temporary, + _PREFERENCES_FILENAME, + src_dir_fd=directory_fd, + dst_dir_fd=directory_fd, + ) + temporary = None + os.fsync(directory_fd) except OSError: raise ExportPreferencesError(_PREFERENCES_SAVE_ERROR) from None finally: - if not installed: - with contextlib.suppress(OSError): - temporary.unlink() + if temporary is not None: + _unlink_quietly(directory_fd, temporary) + _close_quietly(directory_fd) diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index 65a7e589a..374a7443d 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -392,3 +392,33 @@ def test_save_export_preferences_never_chmods_selected_directory( ) assert stat.S_IMODE(selected.stat().st_mode) == 0o750 + + +def test_save_export_preferences_rejects_app_directory_symlink( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A config-child symlink cannot redirect writes or permission changes.""" + config_home = tmp_path / "config" + config_home.mkdir() + selected = tmp_path / "Selected" + selected.mkdir(mode=0o750) + selected.chmod(0o750) + sentinel = selected / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + (config_home / "agentgrep").symlink_to(selected, target_is_directory=True) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + + with pytest.raises( + ExportPreferencesError, + match=r"^Export preferences could not be saved$", + ) as raised: + save_export_preferences( + tmp_path / "home", + ExportPreferences(directory=str(selected)), + ) + + assert stat.S_IMODE(selected.stat().st_mode) == 0o750 + assert sentinel.read_text(encoding="utf-8") == "keep" + assert {entry.name for entry in selected.iterdir()} == {"keep.txt"} + assert str(selected) not in str(raised.value) From c3775c625c7e81ebcb01775c1674bea9ba019086 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:17:37 -0500 Subject: [PATCH 32/71] agentgrep(feat[tui]): Complete export directories why: Directory completion must remain responsive and lifecycle-safe while users edit paths in the staged export dialog. what: - Own a literal six-row popup behind a 150 ms debounce and bounded, cooperative worker scan. - Preserve typed path prefixes while excluding symlinks and stale results. - Prove keyboard, geometry, scan bounds, and explicit pump-thread roles. --- src/agentgrep/ui/widgets/__init__.py | 8 + src/agentgrep/ui/widgets/directory_popup.py | 361 ++++++++++++++++++ tests/test_ui_export_directory_popup.py | 395 ++++++++++++++++++++ 3 files changed, 764 insertions(+) create mode 100644 src/agentgrep/ui/widgets/directory_popup.py create mode 100644 tests/test_ui_export_directory_popup.py diff --git a/src/agentgrep/ui/widgets/__init__.py b/src/agentgrep/ui/widgets/__init__.py index 6567237ae..c6c01eb65 100644 --- a/src/agentgrep/ui/widgets/__init__.py +++ b/src/agentgrep/ui/widgets/__init__.py @@ -12,6 +12,11 @@ import logging from agentgrep.ui.widgets.detail import DetailScroll +from agentgrep.ui.widgets.directory_popup import ( + DirectoryCandidate, + DirectoryCompletionPopup, + ExportDirectoryPicker, +) from agentgrep.ui.widgets.dropdown import CompletionDropdown from agentgrep.ui.widgets.history import HistoryRecall from agentgrep.ui.widgets.inputs import DetailFindInput, FilterInput, SearchInput @@ -57,6 +62,9 @@ "DetailFocusRequested", "DetailScroll", "DetailScrollChanged", + "DirectoryCandidate", + "DirectoryCompletionPopup", + "ExportDirectoryPicker", "FilterCompleted", "FilterHeader", "FilterInput", diff --git a/src/agentgrep/ui/widgets/directory_popup.py b/src/agentgrep/ui/widgets/directory_popup.py new file mode 100644 index 000000000..8a210f864 --- /dev/null +++ b/src/agentgrep/ui/widgets/directory_popup.py @@ -0,0 +1,361 @@ +"""Bounded directory completion for the export dialog.""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import functools +import itertools +import os +import pathlib +import typing as t + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.timer import Timer +from textual.widgets import Input, OptionList +from textual.widgets.option_list import Option +from textual.worker import NoActiveWorker, get_current_worker + +from agentgrep.ui import _runtime + +__all__ = [ + "DIRECTORY_CANDIDATE_LIMIT", + "DIRECTORY_COMPLETION_DEBOUNCE", + "DIRECTORY_SCAN_LIMIT", + "DirectoryCandidate", + "DirectoryCompletionPopup", + "ExportDirectoryPicker", +] + +DIRECTORY_COMPLETION_DEBOUNCE = 0.15 +DIRECTORY_CANDIDATE_LIMIT = 6 +DIRECTORY_SCAN_LIMIT = 256 +_DIRECTORY_WORKER_GROUP = "export-directory-completion" +_TRUNCATION_LABEL = "… more entries" + + +@dataclasses.dataclass(frozen=True, slots=True) +class DirectoryCandidate: + """One literal completion value and its compact display label.""" + + value: str + label: str + + +@dataclasses.dataclass(frozen=True, slots=True) +class _DirectoryCandidates: + """One bounded directory-enumeration result.""" + + values: tuple[DirectoryCandidate, ...] + truncated: bool + + +def _active_worker_cancelled() -> bool: + """Return whether the calling Textual worker has been cancelled.""" + try: + return get_current_worker().is_cancelled + except NoActiveWorker: + return False + + +def _split_directory_prefix(value: str) -> tuple[pathlib.Path, str, str]: + """Return scan parent, typed parent prefix, and partial basename.""" + if value.endswith(os.sep): + display_parent = value + prefix = "" + else: + _parent, prefix = os.path.split(value) + display_parent = value[: -len(prefix)] if prefix else value + scan_parent = pathlib.Path(display_parent or ".").expanduser() + return scan_parent, display_parent, prefix + + +def _enumerate_directory_candidates( + value: str, + *, + candidate_limit: int, + scan_limit: int, +) -> _DirectoryCandidates: + """Return a bounded page of matching, non-symlink child directories. + + The iterator pulls one row beyond ``scan_limit`` only to determine whether + the result is truncated. That sentinel row is never probed or returned. + + Parameters + ---------- + value : str + Literal directory-input value, possibly ending in a partial basename. + candidate_limit : int + Maximum candidates returned to the UI. + scan_limit : int + Maximum raw directory entries inspected. + + Returns + ------- + _DirectoryCandidates + Bounded literal candidates and whether more raw entries exist. + """ + bounded_candidate_limit = max(candidate_limit, 0) + bounded_scan_limit = max(scan_limit, 0) + if not value or not bounded_candidate_limit or not bounded_scan_limit: + return _DirectoryCandidates((), False) + scan_parent, display_parent, prefix = _split_directory_prefix(value) + matches: list[DirectoryCandidate] = [] + truncated = False + try: + with os.scandir(scan_parent) as entries: + for index, entry in enumerate( + itertools.islice(entries, bounded_scan_limit + 1), + ): + if _active_worker_cancelled(): + return _DirectoryCandidates((), False) + if index == bounded_scan_limit: + truncated = True + break + if not entry.name.startswith(prefix): + continue + try: + is_directory = entry.is_dir(follow_symlinks=False) + except OSError: + continue + if not is_directory: + continue + matches.append( + DirectoryCandidate( + value=f"{display_parent}{entry.name}{os.sep}", + label=entry.name, + ), + ) + except OSError, RuntimeError, ValueError: + return _DirectoryCandidates((), False) + matches.sort(key=lambda candidate: candidate.label.casefold()) + return _DirectoryCandidates(tuple(matches[:bounded_candidate_limit]), truncated) + + +class DirectoryCompletionPopup(OptionList, can_focus=False): + """Non-focusable literal directory completion rows.""" + + def __init__(self) -> None: + super().__init__(markup=False, compact=True) + + +class _DirectoryPathInput(Input): + """Private path editor that delegates completion gestures to its picker.""" + + BINDINGS: t.ClassVar[list[Binding]] = [ + Binding("up", "directory_up", "Previous directory", show=False), + Binding("down", "directory_down", "Next directory", show=False), + Binding("tab", "directory_tab", "Accept directory / next field", show=False), + ] + + def __init__(self, owner: ExportDirectoryPicker, *, value: str) -> None: + self._owner = owner + super().__init__(value=value, placeholder="Export directory") + + @_runtime.pump_only + def on_focus(self) -> None: + """Refresh completion after focus returns to the field.""" + self._owner._schedule_enumeration() + + @_runtime.pump_only + def on_blur(self) -> None: + """Invalidate completion before focus reaches the next field.""" + self._owner._invalidate_completion() + + @_runtime.pump_only + def action_directory_up(self) -> None: + """Move to the previous visible completion.""" + self._owner._move_highlight(-1) + + @_runtime.pump_only + def action_directory_down(self) -> None: + """Move to the next visible completion.""" + self._owner._move_highlight(1) + + @_runtime.pump_only + def action_cursor_right(self, select: bool = False) -> None: + """Accept at the end or retain native cursor movement elsewhere.""" + if not select and self.cursor_at_end and self._owner._accept_highlighted(): + return + super().action_cursor_right(select) + + @_runtime.pump_only + def action_directory_tab(self) -> None: + """Accept an open completion or resume normal focus traversal.""" + if self._owner._accept_highlighted(): + return + self.app.action_focus_next() + + +class ExportDirectoryPicker(Vertical): + """Own an export-directory input and its bounded completion popup.""" + + DEFAULT_CSS = """ + ExportDirectoryPicker { + height: 3; + width: 1fr; + } + ExportDirectoryPicker > Input { + height: 3; + width: 100%; + } + ExportDirectoryPicker > DirectoryCompletionPopup { + overlay: screen; + constrain: inside inside; + display: none; + width: 100%; + max-width: 100%; + height: auto; + max-height: 7; + border: none; + padding: 0; + } + """ + + def __init__(self, value: str, *, id: str | None = None) -> None: # noqa: A002 + super().__init__(id=id) + self._input = _DirectoryPathInput(self, value=value) + self._popup = DirectoryCompletionPopup() + self._candidate_generation = 0 + self._candidate_values: tuple[DirectoryCandidate, ...] = () + self._debounce_timer: Timer | None = None + self._pending_value = value + + @property + def value(self) -> str: + """Return the literal directory field value.""" + return self._input.value + + @value.setter + def value(self, value: str) -> None: + self._input.value = value + + @_runtime.pump_only + def compose(self) -> ComposeResult: + """Compose the private field and overlay exactly once.""" + yield self._input + yield self._popup + + @_runtime.pump_only + def focus_input(self) -> None: + """Focus the picker-owned directory field.""" + self._input.focus() + + @_runtime.pump_only + def on_input_changed(self, event: Input.Changed) -> None: + """Debounce completion for the latest literal input value.""" + if event.input is self._input: + self._schedule_enumeration() + + @_runtime.pump_only + def on_unmount(self) -> None: + """Cancel all completion work before the picker leaves the DOM.""" + self._invalidate_completion() + + @_runtime.pump_only + def _schedule_enumeration(self) -> None: + """Invalidate current chrome and arm one named inactivity timer.""" + self._invalidate_completion() + self._pending_value = self._input.value + if not self._pending_value or not self._input.has_focus: + return + self._debounce_timer = self.set_timer( + DIRECTORY_COMPLETION_DEBOUNCE, + self._debounce_elapsed, + ) + + @_runtime.pump_only + def _debounce_elapsed(self) -> None: + """Launch one worker for the value captured after inactivity.""" + self._debounce_timer = None + value = self._pending_value + generation = self._candidate_generation + if not value or not self.is_mounted or not self._input.has_focus: + return + emit = _runtime.make_gated_emitter( + self.app.call_from_thread, + self._apply_candidates, + generation, + ) + self.run_worker( + functools.partial(self._enumerate_in_thread, value, emit), + name=_DIRECTORY_WORKER_GROUP, + group=_DIRECTORY_WORKER_GROUP, + description="enumerate export directories", + thread=True, + exclusive=True, + exit_on_error=False, + ) + + @_runtime.offload + def _enumerate_in_thread( + self, + value: str, + emit: cabc.Callable[[object], None], + ) -> None: + """Enumerate one immutable snapshot away from the pump.""" + event = _enumerate_directory_candidates( + value, + candidate_limit=DIRECTORY_CANDIDATE_LIMIT, + scan_limit=DIRECTORY_SCAN_LIMIT, + ) + if not _active_worker_cancelled(): + emit(event) + + @_runtime.pump_only + def _apply_candidates(self, generation: int, event: object) -> None: + """Apply only a current focused picker's bounded worker result.""" + if ( + generation != self._candidate_generation + or not self.is_mounted + or not self._input.has_focus + or not isinstance(event, _DirectoryCandidates) + ): + return + self._candidate_values = event.values + options: list[Option] = [Option(candidate.label) for candidate in event.values] + if event.truncated: + options.append(Option(_TRUNCATION_LABEL, disabled=True)) + self._popup.set_options(options) + self._popup.highlighted = 0 if event.values else None + self._popup.display = bool(options) + + @_runtime.pump_only + def _invalidate_completion(self) -> None: + """Stop pending work, advance generation, and clear completion chrome.""" + if self._debounce_timer is not None: + self._debounce_timer.stop() + self._debounce_timer = None + self._candidate_generation += 1 + self.workers.cancel_group(self, _DIRECTORY_WORKER_GROUP) + self._candidate_values = () + self._popup.clear_options() + self._popup.display = False + + @_runtime.pump_only + def _move_highlight(self, step: int) -> None: + """Move through selectable completion rows with wraparound.""" + if not self._popup.display or not self._candidate_values: + return + if step < 0: + self._popup.action_cursor_up() + else: + self._popup.action_cursor_down() + + @_runtime.pump_only + def _accept_highlighted(self) -> bool: + """Replace the field with the highlighted selectable value.""" + if not self._popup.display or self._popup.highlighted is None: + return False + index = self._popup.highlighted + if not 0 <= index < len(self._candidate_values): + return False + candidate = self._candidate_values[index] + self._popup.display = False + self._popup.clear_options() + self._candidate_values = () + self._input.value = candidate.value + self._input.cursor_position = len(candidate.value) + return True diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py new file mode 100644 index 000000000..54a380937 --- /dev/null +++ b/tests/test_ui_export_directory_popup.py @@ -0,0 +1,395 @@ +"""Contract tests for bounded export-directory completion.""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import os +import pathlib +import threading +import time +import typing as t + +import pytest +from textual.app import App, ComposeResult +from textual.pilot import Pilot +from textual.widgets import Input, OptionList + +import agentgrep.ui.widgets as widgets +from agentgrep.ui import _runtime +from agentgrep.ui.widgets import directory_popup +from agentgrep.ui.widgets.directory_popup import ( + DIRECTORY_CANDIDATE_LIMIT, + DIRECTORY_COMPLETION_DEBOUNCE, + DIRECTORY_SCAN_LIMIT, + DirectoryCandidate, + DirectoryCompletionPopup, + ExportDirectoryPicker, +) + + +class _DirectoryPopupHost(App[None]): + """Minimal export-dialog edit stage for Pilot interaction tests.""" + + CSS = """ + Screen { layout: vertical; } + #directory { width: 100%; } + #filename { height: 3; } + """ + + def compose(self) -> ComposeResult: + """Compose the owning picker and the next focus target.""" + yield ExportDirectoryPicker(value="", id="directory") + yield Input(placeholder="Filename", id="filename") + + def on_mount(self) -> None: + """Bind the pump guard and focus the picker input.""" + _runtime.bind_pump_thread() + self.query_one(ExportDirectoryPicker).focus_input() + + def on_unmount(self) -> None: + """Release the global test guard binding.""" + _runtime.unbind_pump_thread() + + +async def _wait_for( + pilot: Pilot[None], + predicate: cabc.Callable[[], bool], + *, + timeout: float = 3.0, +) -> None: + """Yield to workers and the pump until ``predicate`` succeeds.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + await pilot.pause(0.01) + pytest.fail("timed out waiting for directory candidates") + + +def _popup(app: _DirectoryPopupHost) -> DirectoryCompletionPopup: + """Return the picker-owned literal completion popup.""" + return app.query_one(DirectoryCompletionPopup) + + +def _prompts(popup: DirectoryCompletionPopup) -> tuple[str, ...]: + """Return popup labels exactly as rendered.""" + return tuple(str(option.prompt) for option in popup.options) + + +def test_export_directory_picker_interface_is_available_and_immutable() -> None: + """The widgets package exports the owning picker and immutable row type.""" + assert widgets.ExportDirectoryPicker is ExportDirectoryPicker + assert widgets.DirectoryCandidate is DirectoryCandidate + assert issubclass(DirectoryCompletionPopup, OptionList) + candidate = DirectoryCandidate(value="./alpha/", label="alpha") + mutable_candidate = t.cast("t.Any", candidate) + + with pytest.raises(dataclasses.FrozenInstanceError): + mutable_candidate.label = "changed" + + +async def test_directory_enumeration_waits_for_inactivity( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only the latest value starts enumeration after 150 ms of inactivity.""" + root = tmp_path / "choices" + root.mkdir() + (root / "alpha").mkdir() + calls: list[tuple[str, float]] = [] + original = directory_popup._enumerate_directory_candidates + + def observed(value: str, *, candidate_limit: int, scan_limit: int) -> object: + calls.append((value, time.monotonic())) + return original(value, candidate_limit=candidate_limit, scan_limit=scan_limit) + + monkeypatch.setattr(directory_popup, "_enumerate_directory_candidates", observed) + app = _DirectoryPopupHost() + async with app.run_test(size=(60, 16)) as pilot: + picker = app.query_one(ExportDirectoryPicker) + picker.value = f"{root}{os.sep}a" + await pilot.pause(DIRECTORY_COMPLETION_DEBOUNCE / 2) + picker.value = f"{root}{os.sep}al" + changed_at = time.monotonic() + await pilot.pause(DIRECTORY_COMPLETION_DEBOUNCE - 0.04) + assert calls == [] + + await _wait_for(pilot, lambda: bool(calls)) + assert [value for value, _started_at in calls] == [f"{root}{os.sep}al"] + assert calls[0][1] - changed_at >= DIRECTORY_COMPLETION_DEBOUNCE - 0.02 + + +class _InstrumentedEntry: + """A scandir row that records directory probes.""" + + def __init__(self, name: str, checks: list[tuple[str, bool]]) -> None: + self.name = name + self._checks = checks + + def is_dir(self, *, follow_symlinks: bool) -> bool: + """Record one no-follow directory check.""" + self._checks.append((self.name, follow_symlinks)) + return True + + +class _InstrumentedScandir: + """Context-managed iterator that records raw pulls.""" + + def __init__(self, entries: list[_InstrumentedEntry]) -> None: + self._entries = iter(entries) + self.pulls = 0 + + def __enter__(self) -> _InstrumentedScandir: + return self + + def __exit__(self, *_args: object) -> None: + return None + + def __iter__(self) -> _InstrumentedScandir: + return self + + def __next__(self) -> _InstrumentedEntry: + entry = next(self._entries) + self.pulls += 1 + return entry + + +def test_directory_scan_has_raw_bound_and_truncation_sentinel( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The 257th raw row detects truncation without a directory probe.""" + checks: list[tuple[str, bool]] = [] + entries = [ + _InstrumentedEntry(f"candidate-{index:03}", checks) + for index in range(DIRECTORY_SCAN_LIMIT + 25) + ] + scandir = _InstrumentedScandir(entries) + monkeypatch.setattr(directory_popup.os, "scandir", lambda _path: scandir) + + result = directory_popup._enumerate_directory_candidates( + "./", + candidate_limit=DIRECTORY_CANDIDATE_LIMIT, + scan_limit=DIRECTORY_SCAN_LIMIT, + ) + + assert scandir.pulls == DIRECTORY_SCAN_LIMIT + 1 + assert len(checks) == DIRECTORY_SCAN_LIMIT + assert all(follow_symlinks is False for _, follow_symlinks in checks) + assert len(result.values) == DIRECTORY_CANDIDATE_LIMIT + assert result.truncated is True + + +def test_symlink_directories_are_not_candidates(tmp_path: pathlib.Path) -> None: + """Completion does not offer a symlink rejected by export safety.""" + target = tmp_path / "target" + target.mkdir() + (tmp_path / "alias").symlink_to(target, target_is_directory=True) + + result = directory_popup._enumerate_directory_candidates( + f"{tmp_path}{os.sep}a", + candidate_limit=DIRECTORY_CANDIDATE_LIMIT, + scan_limit=DIRECTORY_SCAN_LIMIT, + ) + + assert result.values == () + + +@pytest.mark.parametrize( + ("typed", "expected"), + ( + ("./choices/a", "./choices/alpha/"), + ("~/choices/a", "~/choices/alpha/"), + ("{absolute}/a", "{absolute}/alpha/"), + ), +) +def test_candidate_labels_are_basenames_and_values_preserve_prefix( + typed: str, + expected: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Display labels stay compact without rewriting the user's path prefix.""" + choices = tmp_path / "choices" + choices.mkdir() + (choices / "alpha").mkdir() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path)) + typed = typed.format(absolute=choices) + expected = expected.format(absolute=choices) + + result = directory_popup._enumerate_directory_candidates( + typed, + candidate_limit=DIRECTORY_CANDIDATE_LIMIT, + scan_limit=DIRECTORY_SCAN_LIMIT, + ) + + assert result.values == (DirectoryCandidate(value=expected, label="alpha"),) + + +async def test_popup_is_literal_bounded_off_pump_and_reports_truncation( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Only six literal basename rows cross the worker boundary.""" + root = tmp_path / "choices" + root.mkdir() + for index in range(DIRECTORY_SCAN_LIMIT + 1): + (root / f"candidate-[{index:03}]").mkdir() + (root / "not-a-directory.md").write_text("file", encoding="utf-8") + scan_threads: list[int] = [] + original_scandir = os.scandir + + def observed_scandir(path: str | os.PathLike[str]) -> t.Any: + scan_threads.append(threading.get_ident()) + return original_scandir(path) + + monkeypatch.setattr(directory_popup.os, "scandir", observed_scandir) + pump_thread = threading.get_ident() + app = _DirectoryPopupHost() + async with app.run_test(size=(60, 16)) as pilot: + picker = app.query_one(ExportDirectoryPicker) + popup = _popup(app) + picker.value = f"{root}{os.sep}" + await _wait_for(pilot, lambda: popup.option_count == DIRECTORY_CANDIDATE_LIMIT + 1) + + assert _prompts(popup)[-1] == "… more entries" + assert len(_prompts(popup)[:-1]) == DIRECTORY_CANDIDATE_LIMIT + assert all(prompt.startswith("candidate-[") for prompt in _prompts(popup)[:-1]) + assert popup.get_option_at_index(DIRECTORY_CANDIDATE_LIMIT).disabled is True + assert popup._markup is False + assert scan_threads and all(thread_id != pump_thread for thread_id in scan_threads) + + +async def test_up_down_wrap_and_right_accepts_only_at_end(tmp_path: pathlib.Path) -> None: + """Navigation wraps while mid-string Right retains native cursor movement.""" + root = tmp_path / "choices" + root.mkdir() + for name in ("alpha", "beta"): + (root / name).mkdir() + app = _DirectoryPopupHost() + async with app.run_test(size=(60, 16)) as pilot: + picker = app.query_one(ExportDirectoryPicker) + field = picker.query_one(Input) + popup = _popup(app) + picker.value = f"{root}{os.sep}" + await _wait_for(pilot, lambda: popup.option_count == 2) + + await pilot.press("up") + assert popup.highlighted == 1 + await pilot.press("down") + assert popup.highlighted == 0 + + original = picker.value + field.cursor_position = len(original) - 1 + await pilot.press("right") + assert picker.value == original + assert field.cursor_position == len(original) + + await pilot.press("right") + assert picker.value == f"{root}{os.sep}alpha{os.sep}" + assert field.has_focus + + +async def test_tab_accepts_only_when_open_then_traverses(tmp_path: pathlib.Path) -> None: + """Tab accepts one visible row, then resumes normal focus traversal.""" + root = tmp_path / "choices" + root.mkdir() + (root / "child").mkdir() + app = _DirectoryPopupHost() + async with app.run_test(size=(60, 16)) as pilot: + picker = app.query_one(ExportDirectoryPicker) + filename = app.query_one("#filename", Input) + popup = _popup(app) + picker.value = f"{root}{os.sep}ch" + await _wait_for(pilot, lambda: popup.option_count == 1) + + await pilot.press("tab") + assert picker.value == f"{root}{os.sep}child{os.sep}" + assert picker.query_one(Input).has_focus + + await pilot.press("tab") + assert filename.has_focus + + +async def test_late_directory_result_cannot_reopen_after_tab( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Blur invalidates an already-running completion worker.""" + (tmp_path / "alpha").mkdir() + started = threading.Event() + release = threading.Event() + original = directory_popup._enumerate_directory_candidates + + def delayed(value: str, *, candidate_limit: int, scan_limit: int) -> object: + started.set() + release.wait(1) + return original(value, candidate_limit=candidate_limit, scan_limit=scan_limit) + + monkeypatch.setattr(directory_popup, "_enumerate_directory_candidates", delayed) + app = _DirectoryPopupHost() + async with app.run_test(size=(60, 16)) as pilot: + picker = app.query_one(ExportDirectoryPicker) + picker.value = f"{tmp_path}{os.sep}a" + await _wait_for(pilot, started.is_set) + await pilot.press("tab") + release.set() + await pilot.pause(0.2) + + assert _popup(app).display is False + assert app.query_one("#filename", Input).has_focus + + +async def test_unmount_cancels_worker_and_invalidates_generation( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A removed picker cannot receive completion chrome from its worker.""" + (tmp_path / "alpha").mkdir() + started = threading.Event() + release = threading.Event() + original = directory_popup._enumerate_directory_candidates + + def delayed(value: str, *, candidate_limit: int, scan_limit: int) -> object: + started.set() + release.wait(1) + return original(value, candidate_limit=candidate_limit, scan_limit=scan_limit) + + monkeypatch.setattr(directory_popup, "_enumerate_directory_candidates", delayed) + app = _DirectoryPopupHost() + async with app.run_test(size=(60, 16)) as pilot: + picker = app.query_one(ExportDirectoryPicker) + popup = _popup(app) + picker.value = f"{tmp_path}{os.sep}a" + await _wait_for(pilot, started.is_set) + generation = picker._candidate_generation + workers = tuple( + worker for worker in picker.workers if worker.group == "export-directory-completion" + ) + + await picker.remove() + release.set() + await pilot.pause(0.05) + + assert picker._candidate_generation > generation + assert workers and all(worker.is_cancelled for worker in workers) + assert popup.display is False + assert not app.query(ExportDirectoryPicker) + + +async def test_popup_stays_within_picker_at_compact_geometry(tmp_path: pathlib.Path) -> None: + """The borderless overlay never exceeds its owning picker at 60 by 16.""" + (tmp_path / "alpha").mkdir() + app = _DirectoryPopupHost() + async with app.run_test(size=(60, 16)) as pilot: + picker = app.query_one(ExportDirectoryPicker) + popup = _popup(app) + picker.value = f"{tmp_path}{os.sep}a" + await _wait_for(pilot, lambda: popup.option_count == 1) + await pilot.pause() + + assert popup.styles.border.top[0] == "" + assert popup.region.x >= picker.region.x + assert popup.region.right <= picker.region.right + assert popup.region.width <= picker.region.width From 0410bde94afa2dd2b9c705985cf7ee8cdd23729f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:25:26 -0500 Subject: [PATCH 33/71] agentgrep(feat[tui]): Add export dialog why: Selected-record exports need an explicit, no-clobber destination review without blocking Textual's message pump. what: - Add staged edit, validation, review, and saving states with literal destination previews. - Offload directory authority checks behind a typed generation gate. - Cover keyboard flow, retained errors, compact layout, and pump roles. --- src/agentgrep/ui/styles.tcss | 40 ++ src/agentgrep/ui/widgets/__init__.py | 4 + src/agentgrep/ui/widgets/export_dialog.py | 451 ++++++++++++++++++++++ tests/test_ui_export_dialog.py | 364 +++++++++++++++++ 4 files changed, 859 insertions(+) create mode 100644 src/agentgrep/ui/widgets/export_dialog.py create mode 100644 tests/test_ui_export_dialog.py diff --git a/src/agentgrep/ui/styles.tcss b/src/agentgrep/ui/styles.tcss index 905408330..be1c07abd 100644 --- a/src/agentgrep/ui/styles.tcss +++ b/src/agentgrep/ui/styles.tcss @@ -676,3 +676,43 @@ HistoryRecall, HistoryRecall:ansi { #history-footer { color: $ag-dim; } + +/* Selected-record export: one quiet edit/review flow. The exact basename is + the only signature treatment; labels and state copy stay subordinate. */ +ExportDialog { + background: transparent; +} +#export-dialog { + background: ansi_default; +} +.export-label { + color: $ag-muted; +} +#export-preview, +#export-review-filename { + color: $accent; + text-style: bold; +} +#export-review-directory { + color: $text; +} +#export-error { + color: $error; +} +#export-edit-footer, +#export-review-status { + color: $ag-dim; +} +#export-confirm, +#export-confirm:focus { + border: none; + background: transparent; + background-tint: $foreground 0%; + padding: 0; +} +#export-confirm > .option-list--option-highlighted, +#export-confirm:focus > .option-list--option-highlighted { + color: auto; + background: $ag-state-selected-bg; + text-style: none; +} diff --git a/src/agentgrep/ui/widgets/__init__.py b/src/agentgrep/ui/widgets/__init__.py index c6c01eb65..7bf7050c5 100644 --- a/src/agentgrep/ui/widgets/__init__.py +++ b/src/agentgrep/ui/widgets/__init__.py @@ -18,6 +18,7 @@ ExportDirectoryPicker, ) from agentgrep.ui.widgets.dropdown import CompletionDropdown +from agentgrep.ui.widgets.export_dialog import ExportDialog, ExportDraft, ExportIntent from agentgrep.ui.widgets.history import HistoryRecall from agentgrep.ui.widgets.inputs import DetailFindInput, FilterInput, SearchInput from agentgrep.ui.widgets.messages import ( @@ -64,7 +65,10 @@ "DetailScrollChanged", "DirectoryCandidate", "DirectoryCompletionPopup", + "ExportDialog", "ExportDirectoryPicker", + "ExportDraft", + "ExportIntent", "FilterCompleted", "FilterHeader", "FilterInput", diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py new file mode 100644 index 000000000..865e65812 --- /dev/null +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -0,0 +1,451 @@ +"""Staged, no-clobber export confirmation for one selected record.""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import datetime +import functools +import os +import pathlib +import typing as t + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.content import Content +from textual.screen import ModalScreen +from textual.widgets import Input, OptionList, Static +from textual.worker import NoActiveWorker, get_current_worker + +from agentgrep.ui import _runtime +from agentgrep.ui._export_preferences import ( + ExportPreferences, + ExportPreferencesError, + render_export_filename, + resolve_export_directory, +) +from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker + +__all__ = ["ExportDialog", "ExportDraft", "ExportIntent"] + +_VALIDATION_WORKER_GROUP = "export-dialog-validation" +_DIRECTORY_ERROR = "Export directory is invalid" +_DIRECTORY_UNAVAILABLE_ERROR = "Export directory is unavailable" +_DIRECTORY_ACCESS_ERROR = "Export directory is not writable" +_DESTINATION_EXISTS_ERROR = "Export destination already exists" + +ExportPhase = t.Literal["edit", "validating", "review", "saving"] + + +@dataclasses.dataclass(frozen=True, slots=True) +class ExportDraft: + """One retained edit-stage snapshot.""" + + directory: str + filename_template: str + timestamp: datetime.datetime + + +@dataclasses.dataclass(frozen=True, slots=True) +class ExportIntent: + """One exact reviewed destination and the preferences that produced it.""" + + destination: pathlib.Path + preferences: ExportPreferences + + +@dataclasses.dataclass(frozen=True, slots=True) +class _ValidationResult: + """One typed, path-free validator result.""" + + intent: ExportIntent | None = None + error: str | None = None + + +def _active_worker_cancelled() -> bool: + """Return whether the calling Textual worker has been cancelled.""" + try: + return get_current_worker().is_cancelled + except NoActiveWorker: + return False + + +def _validate_export_draft( + draft: ExportDraft, + *, + title: str, + fallback_title: str, + home: pathlib.Path, +) -> _ValidationResult: + """Validate one immutable draft away from the Textual pump.""" + try: + filename = render_export_filename( + draft.filename_template, + title, + fallback_title, + draft.timestamp, + ) + directory = resolve_export_directory(draft.directory, home) + except ExportPreferencesError: + return _ValidationResult(error=_DIRECTORY_ERROR) + + try: + if directory.is_symlink() or not directory.is_dir(): + return _ValidationResult(error=_DIRECTORY_UNAVAILABLE_ERROR) + if not os.access(directory, os.W_OK | os.X_OK): + return _ValidationResult(error=_DIRECTORY_ACCESS_ERROR) + destination = directory / filename + if os.path.lexists(destination): + return _ValidationResult(error=_DESTINATION_EXISTS_ERROR) + except OSError, RuntimeError, ValueError: + return _ValidationResult(error=_DIRECTORY_UNAVAILABLE_ERROR) + + return _ValidationResult( + intent=ExportIntent( + destination=destination, + preferences=ExportPreferences( + directory=draft.directory, + filename_template=draft.filename_template, + ), + ), + ) + + +class ExportDialog(ModalScreen[None]): + """Edit, validate, review, and retain one selected-record export.""" + + BINDINGS: t.ClassVar[list[Binding]] = [ + Binding("escape", "escape", "Back / Cancel", priority=True, show=False), + Binding("ctrl+c", "cancel", "Cancel", priority=True, show=False), + Binding("n", "review_no", "No", priority=True, show=False), + Binding("y", "review_save", "Save", priority=True, show=False), + ] + + DEFAULT_CSS = """ + ExportDialog { + align: center middle; + } + #export-dialog { + width: 100%; + max-width: 72; + height: 100%; + max-height: 18; + padding: 0 2; + } + #export-edit, #export-review { + width: 100%; + height: 1fr; + } + .export-label { + width: 100%; + height: 1; + } + #export-directory, #export-template { + width: 100%; + height: 3; + } + #export-preview, #export-review-directory, #export-review-filename { + width: 100%; + height: auto; + max-height: 3; + text-wrap: wrap; + } + #export-error, #export-edit-footer, #export-review-status { + width: 100%; + height: 1; + } + #export-review { + display: none; + } + #export-confirm { + width: 100%; + height: 2; + } + """ + + def __init__( + self, + title: str, + fallback_title: str, + home: pathlib.Path, + preferences: ExportPreferences, + on_confirm: cabc.Callable[[ExportIntent], bool], + timestamp: datetime.datetime | None = None, + ) -> None: + super().__init__() + self._title = title + self._fallback_title = fallback_title + self._home = home + self._on_confirm = on_confirm + self._timestamp = timestamp or datetime.datetime.now().astimezone() + self._initial_preferences = preferences + self._phase: ExportPhase = "edit" + self._validation_generation = 0 + self._intent: ExportIntent | None = None + self._edit_focus = "template" + + @property + def phase(self) -> ExportPhase: + """Return the dialog's current interaction phase.""" + return self._phase + + @_runtime.pump_only + def compose(self) -> ComposeResult: + """Compose one quiet edit/review flow with literal output surfaces.""" + with Vertical(id="export-dialog"): + with Vertical(id="export-edit"): + yield Static("Directory", classes="export-label") + yield ExportDirectoryPicker( + value=self._initial_preferences.directory, + id="export-directory", + ) + yield Static("Template", classes="export-label") + yield Input( + value=self._initial_preferences.filename_template, + placeholder="Filename template", + id="export-template", + ) + yield Static("Exact filename", classes="export-label") + yield Static("", id="export-preview", markup=False) + yield Static("", id="export-error", markup=False) + yield Static( + "Tab to move · Enter to review · Ctrl-C to cancel", + id="export-edit-footer", + markup=False, + ) + with Vertical(id="export-review"): + yield Static("Directory", classes="export-label") + yield Static("", id="export-review-directory", markup=False) + yield Static("Filename", classes="export-label") + yield Static("", id="export-review-filename", markup=False) + yield OptionList("No", "Save", id="export-confirm", markup=False, compact=True) + yield Static("", id="export-review-status", markup=False) + + @_runtime.pump_only + def on_mount(self) -> None: + """Render the frozen preview and focus the directory editor.""" + self._refresh_preview() + self.query_one("#export-directory", ExportDirectoryPicker).focus_input() + + @_runtime.pump_only + def on_unmount(self) -> None: + """Invalidate and cancel validator work before teardown.""" + self._validation_generation += 1 + self.workers.cancel_group(self, _VALIDATION_WORKER_GROUP) + + @_runtime.pump_only + def on_input_changed(self, event: Input.Changed) -> None: + """Refresh the pure filename preview after template edits.""" + if event.input.id == "export-template" and self._phase == "edit": + self._refresh_preview() + + @_runtime.pump_only + def on_input_submitted(self, event: Input.Submitted) -> None: + """Advance directory to template, then validate the submitted draft.""" + directory_input = self.query_one("#export-directory", ExportDirectoryPicker).query_one( + Input, + ) + if event.input is directory_input and self._phase == "edit": + event.stop() + self.query_one("#export-template", Input).focus() + return + if event.input.id == "export-template" and self._phase == "edit": + event.stop() + self._start_validation() + + @_runtime.pump_only + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Handle the two literal review rows while review is active.""" + if event.option_list.id != "export-confirm" or self._phase != "review": + return + if event.option_index == 0: + self._show_edit() + elif event.option_index == 1: + self._confirm() + + @_runtime.pump_only + def action_escape(self) -> None: + """Return from review without changing the retained draft.""" + if self._phase == "review": + self._show_edit() + + @_runtime.pump_only + def action_cancel(self) -> None: + """Dismiss from every phase without delegating a write.""" + self.dismiss(None) + + @_runtime.pump_only + def action_review_no(self) -> None: + """Return to the retained draft only while reviewing.""" + if self._phase == "review": + self._show_edit() + + @_runtime.pump_only + def action_review_save(self) -> None: + """Delegate the reviewed intent only while reviewing.""" + if self._phase == "review": + self._confirm() + + @_runtime.pump_only + def export_failed(self, message: str) -> None: + """Restore editing with retained values after an asynchronous failure.""" + if self.is_mounted and self._phase == "saving": + self._show_edit(message) + + @_runtime.pump_only + def export_succeeded(self) -> None: + """Dismiss after the asynchronous writer reports success.""" + if self.is_mounted and self._phase == "saving": + self.dismiss(None) + + @_runtime.pump_only + def _refresh_preview(self) -> bool: + """Compile only the frozen, Textual-free filename preview.""" + template = self.query_one("#export-template", Input).value + try: + filename = render_export_filename( + template, + self._title, + self._fallback_title, + self._timestamp, + ) + except ExportPreferencesError as error: + self.query_one("#export-preview", Static).update(Content("")) + self.query_one("#export-error", Static).update(Content(str(error))) + return False + self.query_one("#export-preview", Static).update(Content(filename)) + self.query_one("#export-error", Static).update(Content("")) + return True + + @_runtime.pump_only + def _start_validation(self) -> None: + """Snapshot the draft and launch one exclusive validator worker.""" + if not self._refresh_preview(): + return + template = self.query_one("#export-template", Input) + picker = self.query_one("#export-directory", ExportDirectoryPicker) + self._edit_focus = "template" if template.has_focus else "directory" + draft = ExportDraft( + directory=picker.value, + filename_template=template.value, + timestamp=self._timestamp, + ) + self._phase = "validating" + picker.disabled = True + template.disabled = True + self.query_one("#export-edit-footer", Static).update(Content("Validating…")) + self._validation_generation += 1 + generation = self._validation_generation + emit = _runtime.make_gated_emitter( + self.app.call_from_thread, + self._apply_validation, + generation, + ) + self.run_worker( + functools.partial( + self._validate_in_thread, + draft, + self._title, + self._fallback_title, + self._home, + emit, + ), + name=_VALIDATION_WORKER_GROUP, + group=_VALIDATION_WORKER_GROUP, + description="validate export destination", + thread=True, + exclusive=True, + exit_on_error=False, + ) + + @_runtime.offload + def _validate_in_thread( + self, + draft: ExportDraft, + title: str, + fallback_title: str, + home: pathlib.Path, + emit: cabc.Callable[[object], None], + ) -> None: + """Validate one immutable snapshot away from the pump.""" + result = _validate_export_draft( + draft, + title=title, + fallback_title=fallback_title, + home=home, + ) + if not _active_worker_cancelled(): + emit(result) + + @_runtime.pump_only + def _apply_validation(self, generation: int, event: object) -> None: + """Apply only the current typed validator result.""" + if ( + generation != self._validation_generation + or not self.is_mounted + or self._phase != "validating" + or not isinstance(event, _ValidationResult) + ): + return + if event.intent is None: + self._show_edit(event.error or _DIRECTORY_UNAVAILABLE_ERROR) + return + self._intent = event.intent + self._show_review(event.intent) + + @_runtime.pump_only + def _show_edit(self, error: str | None = None) -> None: + """Restore the retained edit stage and its prior focus.""" + self._phase = "edit" + self._intent = None + edit = self.query_one("#export-edit", Vertical) + review = self.query_one("#export-review", Vertical) + edit.display = True + review.display = False + picker = self.query_one("#export-directory", ExportDirectoryPicker) + template = self.query_one("#export-template", Input) + picker.disabled = False + template.disabled = False + self.query_one("#export-edit-footer", Static).update( + Content("Tab to move · Enter to review · Ctrl-C to cancel"), + ) + self._refresh_preview() + if error is not None: + self.query_one("#export-error", Static).update(Content(error)) + if self._edit_focus == "directory": + picker.focus_input() + else: + template.focus() + + @_runtime.pump_only + def _show_review(self, intent: ExportIntent) -> None: + """Show the literal directory and exact basename with No selected.""" + self._phase = "review" + self.query_one("#export-edit", Vertical).display = False + self.query_one("#export-review", Vertical).display = True + self.query_one("#export-review-directory", Static).update( + Content(intent.preferences.directory), + ) + self.query_one("#export-review-filename", Static).update( + Content(intent.destination.name), + ) + status = self.query_one("#export-review-status", Static) + status.update(Content("")) + confirm = self.query_one("#export-confirm", OptionList) + confirm.disabled = False + confirm.highlighted = 0 + confirm.focus() + + @_runtime.pump_only + def _confirm(self) -> None: + """Delegate once and retain the modal while the writer is active.""" + intent = self._intent + if self._phase != "review" or intent is None: + return + if not self._on_confirm(intent): + return + self._phase = "saving" + confirm = self.query_one("#export-confirm", OptionList) + confirm.disabled = True + self.query_one("#export-review-status", Static).update(Content("Saving…")) diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py new file mode 100644 index 000000000..f6c53055a --- /dev/null +++ b/tests/test_ui_export_dialog.py @@ -0,0 +1,364 @@ +"""Pilot contracts for the staged TUI export dialog.""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import datetime +import os +import pathlib +import threading +import time +import typing as t + +import pytest +from textual.app import App +from textual.pilot import Pilot +from textual.widgets import Input, OptionList, Static + +import agentgrep.ui.widgets as widgets +from agentgrep.ui import _runtime +from agentgrep.ui._export_preferences import ExportPreferences +from agentgrep.ui.widgets import ExportDialog, ExportDraft, ExportIntent +from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker + +_TIMESTAMP = datetime.datetime(2026, 7, 14, 9, 8, 7) + + +class _ExportDialogHost(App[None]): + """Minimal host that pushes one export dialog and captures dismissal.""" + + def __init__( + self, + home: pathlib.Path, + on_confirm: cabc.Callable[[ExportIntent], bool], + *, + directory: str | None = None, + template: str = "{date} {time} - {title}.md", + title: str = "Machine [Readable] Title", + timestamp: datetime.datetime = _TIMESTAMP, + ) -> None: + super().__init__() + self._dialog = ExportDialog( + title=title, + fallback_title="record", + home=home, + preferences=ExportPreferences( + directory=directory or str(home), + filename_template=template, + ), + on_confirm=on_confirm, + timestamp=timestamp, + ) + self.dismissed: object = _UNSET + + @_runtime.pump_only + def on_mount(self) -> None: + """Bind the pump guard and open the dialog.""" + _runtime.bind_pump_thread() + self.push_screen(self._dialog, self._capture) + + @_runtime.pump_only + def on_unmount(self) -> None: + """Release the process-wide pump binding.""" + _runtime.unbind_pump_thread() + + @_runtime.pump_only + def _capture(self, value: None) -> None: + """Capture the modal dismissal callback.""" + self.dismissed = value + + +_UNSET = object() + + +async def _wait_for( + pilot: Pilot[None], + predicate: cabc.Callable[[], bool], + *, + timeout: float = 3.0, +) -> None: + """Yield to workers and the pump until ``predicate`` succeeds.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + await pilot.pause(0.01) + pytest.fail("timed out waiting for export-dialog state") + + +def _dialog(app: _ExportDialogHost) -> ExportDialog: + """Return the mounted export dialog.""" + return t.cast("ExportDialog", app.screen) + + +def _text(app: _ExportDialogHost, selector: str) -> str: + """Return the literal plain text last assigned to a ``Static``.""" + static = app.screen.query_one(selector, Static) + content = getattr(static, "_Static__content", "") + return getattr(content, "plain", str(content)) + + +async def _open_review(app: _ExportDialogHost, pilot: Pilot[None]) -> None: + """Submit the default draft and wait for its review stage.""" + await pilot.press("tab", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase == "review") + + +def test_export_dialog_interfaces_are_available_and_immutable( + tmp_path: pathlib.Path, +) -> None: + """The package exports the modal and its immutable boundary values.""" + assert widgets.ExportDialog is ExportDialog + assert widgets.ExportDraft is ExportDraft + assert widgets.ExportIntent is ExportIntent + + draft = ExportDraft(str(tmp_path), "{title}.md", _TIMESTAMP) + intent = ExportIntent(tmp_path / "record.md", ExportPreferences(str(tmp_path))) + with pytest.raises(dataclasses.FrozenInstanceError): + t.cast("t.Any", draft).directory = "changed" + with pytest.raises(dataclasses.FrozenInstanceError): + t.cast("t.Any", intent).destination = tmp_path / "changed.md" + + +async def test_preview_is_frozen_literal_and_uses_no_filesystem( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Template edits compile only the title and frozen opening timestamp.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.pause() + assert _text(app, "#export-preview") == ("2026-07-14 09-08-07 - machine-readable-title.md") + + unexpected_message = "preview reached the filesystem" + + def unexpected_filesystem(*_args: object, **_kwargs: object) -> t.NoReturn: + raise AssertionError(unexpected_message) + + monkeypatch.setattr(pathlib.Path, "stat", unexpected_filesystem) + monkeypatch.setattr(pathlib.Path, "exists", unexpected_filesystem) + monkeypatch.setattr(pathlib.Path, "is_dir", unexpected_filesystem) + monkeypatch.setattr(pathlib.Path, "is_symlink", unexpected_filesystem) + monkeypatch.setattr(os, "access", unexpected_filesystem) + template = app.screen.query_one("#export-template", Input) + template.value = "{time}-{title}.md" + await pilot.pause() + assert _text(app, "#export-preview") == "09-08-07-machine-readable-title.md" + template.value = "{date}-{time}-{title}.md" + await pilot.pause() + assert _text(app, "#export-preview") == ("2026-07-14-09-08-07-machine-readable-title.md") + + +async def test_enter_moves_directory_to_template(tmp_path: pathlib.Path) -> None: + """Enter in the directory field advances to the filename editor.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.pause() + picker = app.screen.query_one("#export-directory", ExportDirectoryPicker) + assert picker.query_one(Input).has_focus + + await pilot.press("enter") + + assert app.screen.query_one("#export-template", Input).has_focus + assert _dialog(app).phase == "edit" + + +async def test_invalid_template_stays_edit_with_path_free_error( + tmp_path: pathlib.Path, +) -> None: + """An unsafe template never starts validation or exposes a path.""" + seen: list[ExportIntent] = [] + app = _ExportDialogHost(tmp_path, lambda intent: seen.append(intent) or True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.press("tab") + template = app.screen.query_one("#export-template", Input) + template.value = "{unknown}.md" + await pilot.press("enter") + await pilot.pause() + + assert _dialog(app).phase == "edit" + assert _text(app, "#export-error") == "Export filename is invalid" + assert str(tmp_path) not in _text(app, "#export-error") + assert template.has_focus + assert seen == [] + + +async def test_validation_runs_off_pump( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Directory authority checks execute only in the validator worker.""" + access_threads: list[int] = [] + original_access = os.access + + def observed_access(path: os.PathLike[str], mode: int) -> bool: + access_threads.append(threading.get_ident()) + return original_access(path, mode) + + monkeypatch.setattr(os, "access", observed_access) + pump_thread = threading.get_ident() + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + + assert access_threads + assert all(thread_id != pump_thread for thread_id in access_threads) + + +async def test_existing_exact_destination_prevents_review(tmp_path: pathlib.Path) -> None: + """Validation refuses the exact previewed basename instead of clobbering it.""" + destination = tmp_path / "2026-07-14 09-08-07 - machine-readable-title.md" + destination.write_text("already here", encoding="utf-8") + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.press("tab", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase == "edit") + + assert _text(app, "#export-error") == "Export destination already exists" + assert str(tmp_path) not in _text(app, "#export-error") + assert app.screen.query_one("#export-template", Input).has_focus + + +async def test_review_shows_directory_and_filename_literally(tmp_path: pathlib.Path) -> None: + """Review renders user-controlled brackets as text, never as markup.""" + directory = tmp_path / "exports-[literal]" + directory.mkdir() + app = _ExportDialogHost( + tmp_path, + lambda _intent: True, + directory=str(directory), + title="Title [literal]", + ) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + + assert _text(app, "#export-review-directory") == str(directory) + assert _text(app, "#export-review-filename") == ("2026-07-14 09-08-07 - title-literal.md") + confirm = app.screen.query_one("#export-confirm", OptionList) + assert confirm._markup is False + assert confirm.highlighted == 0 + + +async def test_no_returns_to_editor_without_losing_values( + tmp_path: pathlib.Path, +) -> None: + """The default No row restores the editor with its prior draft.""" + seen: list[ExportIntent] = [] + app = _ExportDialogHost(tmp_path, lambda intent: seen.append(intent) or True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.press("tab", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase == "review") + review = app.screen.query_one("#export-confirm", OptionList) + assert review.highlighted == 0 + await pilot.press("enter") + assert app.screen.query_one("#export-directory", ExportDirectoryPicker).value + assert app.screen.query_one("#export-template", Input).value + assert app.screen.query_one("#export-template", Input).has_focus + assert _dialog(app).phase == "edit" + assert seen == [] + + +async def test_repeated_enter_on_default_no_cannot_save(tmp_path: pathlib.Path) -> None: + """Repeated Enter alternates review and edit without selecting Save.""" + seen: list[ExportIntent] = [] + app = _ExportDialogHost(tmp_path, lambda intent: seen.append(intent) or True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + await pilot.press("enter", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase == "review") + await pilot.press("enter") + + assert _dialog(app).phase == "edit" + assert seen == [] + + +@pytest.mark.parametrize("key", ("n", "escape")) +async def test_no_shortcuts_return_to_edit(tmp_path: pathlib.Path, key: str) -> None: + """The explicit No gestures preserve the draft and prior focus.""" + seen: list[ExportIntent] = [] + app = _ExportDialogHost(tmp_path, lambda intent: seen.append(intent) or True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + await pilot.press(key) + + assert _dialog(app).phase == "edit" + assert app.screen.query_one("#export-template", Input).has_focus + assert seen == [] + + +async def test_y_invokes_once_and_enters_saving(tmp_path: pathlib.Path) -> None: + """Save delegates once and disables every further write gesture.""" + seen: list[ExportIntent] = [] + app = _ExportDialogHost(tmp_path, lambda intent: seen.append(intent) or True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + await pilot.press("y", "y", "enter") + + assert _dialog(app).phase == "saving" + assert len(seen) == 1 + assert seen[0] == ExportIntent( + destination=(tmp_path / "2026-07-14 09-08-07 - machine-readable-title.md"), + preferences=ExportPreferences( + directory=str(tmp_path), + filename_template="{date} {time} - {title}.md", + ), + ) + + +async def test_ctrl_c_dismisses_even_while_saving(tmp_path: pathlib.Path) -> None: + """Ctrl-C closes the modal after write delegation as it does while editing.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + await pilot.press("y") + assert _dialog(app).phase == "saving" + + await pilot.press("ctrl+c") + await _wait_for(pilot, lambda: app.dismissed is None) + + assert not app.query(ExportDialog) + + +async def test_export_failed_restores_edit_with_values(tmp_path: pathlib.Path) -> None: + """An asynchronous write error returns to the retained draft.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + directory = app.screen.query_one("#export-directory", ExportDirectoryPicker).value + template = app.screen.query_one("#export-template", Input).value + await pilot.press("y") + _dialog(app).export_failed("Export [failed]") + await pilot.pause() + + assert _dialog(app).phase == "edit" + assert app.screen.query_one("#export-directory", ExportDirectoryPicker).value == directory + assert app.screen.query_one("#export-template", Input).value == template + assert _text(app, "#export-error") == "Export [failed]" + assert app.screen.query_one("#export-template", Input).has_focus + + +async def test_export_succeeded_dismisses(tmp_path: pathlib.Path) -> None: + """An asynchronous write success closes the retained saving modal.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + await pilot.press("y") + _dialog(app).export_succeeded() + await _wait_for(pilot, lambda: app.dismissed is None) + + assert not app.query(ExportDialog) + + +async def test_dialog_fits_compact_terminal_without_horizontal_scroll( + tmp_path: pathlib.Path, +) -> None: + """The single modal stays inside a 60 by 16 terminal in both stages.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.pause() + dialog_body = app.screen.query_one("#export-dialog") + assert dialog_body.region.width <= 60 + assert dialog_body.region.height <= 16 + await _open_review(app, pilot) + assert dialog_body.region.width <= 60 + assert dialog_body.region.height <= 16 From c030a7727401d5d57cdf68d62e61aa185034c03f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:29:19 -0500 Subject: [PATCH 34/71] agentgrep(fix[tui]): Preserve export input why: Review shortcuts must not outrank focused text fields, while Escape must retain its edit-stage cancel behavior. what: - Make n and y non-priority while keeping review shortcuts active. - Restore Escape cancellation outside review and retain the review back-step. - Add mounted input and phase regressions. --- src/agentgrep/ui/widgets/export_dialog.py | 8 ++-- tests/test_ui_export_dialog.py | 47 +++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index 865e65812..2f2e130fb 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -118,8 +118,8 @@ class ExportDialog(ModalScreen[None]): BINDINGS: t.ClassVar[list[Binding]] = [ Binding("escape", "escape", "Back / Cancel", priority=True, show=False), Binding("ctrl+c", "cancel", "Cancel", priority=True, show=False), - Binding("n", "review_no", "No", priority=True, show=False), - Binding("y", "review_save", "Save", priority=True, show=False), + Binding("n", "review_no", "No", show=False), + Binding("y", "review_save", "Save", show=False), ] DEFAULT_CSS = """ @@ -266,9 +266,11 @@ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> No @_runtime.pump_only def action_escape(self) -> None: - """Return from review without changing the retained draft.""" + """Return from review, or cancel from any other phase.""" if self._phase == "review": self._show_edit() + return + self.dismiss(None) @_runtime.pump_only def action_cancel(self) -> None: diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index f6c53055a..0ae8de796 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -121,6 +121,15 @@ def test_export_dialog_interfaces_are_available_and_immutable( t.cast("t.Any", intent).destination = tmp_path / "changed.md" +def test_review_letters_are_non_priority_bindings() -> None: + """Focused editors receive ``n`` and ``y`` before review shortcuts.""" + bindings = {binding.key: binding for binding in ExportDialog.BINDINGS} + + assert bindings["n"].priority is False + assert bindings["y"].priority is False + assert bindings["ctrl+c"].priority is True + + async def test_preview_is_frozen_literal_and_uses_no_filesystem( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -164,6 +173,34 @@ async def test_enter_moves_directory_to_template(tmp_path: pathlib.Path) -> None assert _dialog(app).phase == "edit" +async def test_directory_input_receives_n_and_y(tmp_path: pathlib.Path) -> None: + """Review shortcut letters remain ordinary text in the directory editor.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.pause() + picker = app.screen.query_one("#export-directory", ExportDirectoryPicker) + picker.value = "" + + await pilot.press("n", "y") + + assert picker.value == "ny" + assert _dialog(app).phase == "edit" + + +async def test_template_input_receives_n_and_y(tmp_path: pathlib.Path) -> None: + """Review shortcut letters remain ordinary text in the template editor.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.press("tab") + template = app.screen.query_one("#export-template", Input) + template.value = "" + + await pilot.press("n", "y") + + assert template.value == "ny" + assert _dialog(app).phase == "edit" + + async def test_invalid_template_stays_edit_with_path_free_error( tmp_path: pathlib.Path, ) -> None: @@ -319,6 +356,16 @@ async def test_ctrl_c_dismisses_even_while_saving(tmp_path: pathlib.Path) -> Non assert not app.query(ExportDialog) +async def test_escape_dismisses_from_edit(tmp_path: pathlib.Path) -> None: + """Escape cancels the dialog outside the review back-step.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.press("escape") + await _wait_for(pilot, lambda: app.dismissed is None) + + assert not app.query(ExportDialog) + + async def test_export_failed_restores_edit_with_values(tmp_path: pathlib.Path) -> None: """An asynchronous write error returns to the retained draft.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) From 0e7ab5c1e5bec299e3bb66d24c365b1d48c70901 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:29:47 -0500 Subject: [PATCH 35/71] agentgrep(feat[tui]): Confirm selected exports why: Selected-record exports need reviewed destinations and remembered settings without changing slash-command filesystem behavior. what: - Route e through one retained dialog with exact selection capture. - Persist preferences only after a no-clobber artifact succeeds. - Keep slash exports stateless and guard the new pump callbacks. --- src/agentgrep/ui/_context.py | 7 + src/agentgrep/ui/_export_preferences.py | 4 +- src/agentgrep/ui/app.py | 5 +- src/agentgrep/ui/layouts/hud.py | 106 +++++- tests/test_ui_export.py | 429 +++++++++++++++++++++++- tests/test_ui_export_dialog.py | 7 +- tests/test_ui_export_directory_popup.py | 9 +- tests/test_ui_export_preferences.py | 16 +- 8 files changed, 546 insertions(+), 37 deletions(-) diff --git a/src/agentgrep/ui/_context.py b/src/agentgrep/ui/_context.py index 9a25c64ea..516f4472e 100644 --- a/src/agentgrep/ui/_context.py +++ b/src/agentgrep/ui/_context.py @@ -23,6 +23,7 @@ SearchScope, SearchScopeProvenance, ) + from agentgrep.ui._export_preferences import ExportPreferences from agentgrep.ui._history import HistoryEntry from agentgrep.ui._seams import SearchInvoker @@ -66,6 +67,10 @@ class UiContext: Preloaded query-history snapshot for layouts that expose recall. history_disabled : bool, optional Whether persistent query history is disabled for this session. + export_preferences : ExportPreferences | None, optional + Export settings preloaded before Textual starts. + export_preferences_warning : str | None, optional + Path-free warning produced while preloading export settings. """ home: pathlib.Path @@ -79,3 +84,5 @@ class UiContext: initial_search_text: str | None = None history: tuple[HistoryEntry, ...] = () history_disabled: bool = False + export_preferences: ExportPreferences | None = None + export_preferences_warning: str | None = None diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index 68f39e24d..85f5fbe2c 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -253,7 +253,7 @@ def _parse_preferences(payload: bytes) -> ExportPreferences: filename_template, title="Title", fallback_title="record", - timestamp=datetime.datetime(2000, 1, 1), + timestamp=datetime.datetime(2000, 1, 1, tzinfo=datetime.UTC), ) return ExportPreferences(directory=directory, filename_template=filename_template) @@ -376,7 +376,7 @@ def _serialize_preferences(preferences: ExportPreferences) -> bytes: preferences.filename_template, title="Title", fallback_title="record", - timestamp=datetime.datetime(2000, 1, 1), + timestamp=datetime.datetime(2000, 1, 1, tzinfo=datetime.UTC), ) payload = json.dumps( { diff --git a/src/agentgrep/ui/app.py b/src/agentgrep/ui/app.py index b26782718..76d62204c 100644 --- a/src/agentgrep/ui/app.py +++ b/src/agentgrep/ui/app.py @@ -14,7 +14,7 @@ import pathlib import typing as t -from agentgrep.ui import _history, preferences, registry +from agentgrep.ui import _export_preferences, _history, preferences, registry from agentgrep.ui._context import UiContext if t.TYPE_CHECKING: @@ -199,6 +199,7 @@ def build_streaming_ui_app( resolved_base_conversation_limit = ( query.conversation_limit if resolved_base_effort == "targeted" else None ) + export_preferences_load = _export_preferences.load_export_preferences(home) ctx = UiContext( home=home, invoker=EngineSearchInvoker(home), @@ -211,6 +212,8 @@ def build_streaming_ui_app( initial_search_text=initial_search_text, history=history, history_disabled=history_disabled, + export_preferences=export_preferences_load.preferences, + export_preferences_warning=export_preferences_load.warning, ) config_path = preferences.theme_config_path(home=home) selected_theme = preferences.load_theme_name(config_path) diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index 04e68d393..a973d4ac8 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -35,6 +35,12 @@ from agentgrep.records import SearchRecord from agentgrep.ui import _history, _runtime, commands, theme as ui_theme from agentgrep.ui._context import UiContext +from agentgrep.ui._export_preferences import ( + ExportPreferences, + ExportPreferencesError, + default_export_directory, + save_export_preferences, +) from agentgrep.ui._result_status import depth_offer_typed_directive from agentgrep.ui.completion import QuerySuggester from agentgrep.ui.highlighter import QueryHighlighter @@ -51,6 +57,8 @@ DetailFindInput, DetailFocusRequested, DetailScroll, + ExportDialog, + ExportIntent, FilterHeader, FilterInput, PaneHeader, @@ -87,6 +95,8 @@ class _ExportSnapshot: records: list[SearchRecord] destination: str | None selection: _ExportSelection + preferences: ExportPreferences | None + home: pathlib.Path canceled: threading.Event @@ -98,6 +108,8 @@ class _ExportCompleted: format: str selection: _ExportSelection record_count: int + preferences: ExportPreferences | None + preference_warning: str | None error: str | None @@ -225,6 +237,10 @@ def __init__(self, ctx: UiContext, workflow: Workflow) -> None: self._history_path = _history.history_path(self.home) self._history = list(ctx.history) self._last_recorded_text = self._history[0].text if self._history else "" + self._export_preferences = ctx.export_preferences or ExportPreferences( + directory=str(default_export_directory(self.home)), + ) + self._export_preferences_warning = ctx.export_preferences_warning # Export is a non-supersedable durable action. The pump prepares one # point-in-time result snapshot in bounded chunks, then transfers sole # ownership to a thread worker. A second request remains blocked until @@ -232,6 +248,7 @@ def __init__(self, ctx: UiContext, workflow: Workflow) -> None: self._export_pending: bool = False self._export_generation: int = 0 self._export_cancel_event: threading.Event | None = None + self._export_dialog: ExportDialog | None = None self._results: SearchResultsList | None = None # The detail pane is un-Grouped into two stacked, individually # selectable ``Static``s: the metadata header and the body. A single @@ -599,12 +616,20 @@ def on_mount(self) -> None: # mount focus there even when an initial search hides the filter. self._search_input.focus() self._update_pane_focus() + if self._export_preferences_warning is not None: + self.notify( + self._export_preferences_warning, + title="Export preferences", + severity="warning", + ) + self._export_preferences_warning = None @_runtime.pump_only def on_unmount(self) -> None: """Invalidate export callbacks and cancel work during screen teardown.""" self._export_generation += 1 self._export_pending = False + self._export_dialog = None if self._export_cancel_event is not None: self._export_cancel_event.set() self._export_cancel_event = None @@ -1067,10 +1092,47 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No @_runtime.pump_only def action_export_selected(self) -> None: - """Export the selected record to the private default destination.""" + """Review one exact content-pane selection before exporting it.""" + if self._export_dialog is not None: + return selected = self._selected_export_shortcut_record() - if selected is not None: - self.request_export("", selection="records", selected_record=selected) + if selected is None: + return + dialog = ExportDialog( + title=selected.title or "", + fallback_title=f"{selected.agent}-{selected.kind}", + home=self.home, + preferences=self._export_preferences, + on_confirm=functools.partial(self._confirm_export_dialog, selected), + ) + self._export_dialog = dialog + self.app.push_screen( + dialog, + functools.partial(self._clear_export_dialog, dialog), + ) + + @_runtime.pump_only + def _confirm_export_dialog( + self, + selected: SearchRecord, + intent: ExportIntent, + ) -> bool: + """Start the durable worker for one retained reviewed intent.""" + dialog = self._export_dialog + if dialog is None or not dialog.is_mounted: + return False + return self.request_export( + str(intent.destination), + selection="records", + selected_record=selected, + preferences=intent.preferences, + ) + + @_runtime.pump_only + def _clear_export_dialog(self, dialog: ExportDialog, _result: None) -> None: + """Forget only the retained dialog whose dismissal just completed.""" + if self._export_dialog is dialog: + self._export_dialog = None @_runtime.pump_only def request_export( @@ -1079,6 +1141,7 @@ def request_export( *, selection: _ExportSelection, selected_record: SearchRecord | None = None, + preferences: ExportPreferences | None = None, ) -> bool: """Accept one selected-record or observed-thread export request. @@ -1118,6 +1181,7 @@ def request_export( selected, selection, destination or None, + preferences, active_records, active_count, chrome_generation, @@ -1186,6 +1250,7 @@ async def _snapshot_and_start_export( selected: SearchRecord, selection: _ExportSelection, destination: str | None, + preferences: ExportPreferences | None, active_records: list[SearchRecord], active_count: int, chrome_generation: int, @@ -1247,6 +1312,8 @@ async def yield_and_gate() -> None: records=records, destination=destination, selection=selection, + preferences=preferences, + home=self.home, canceled=canceled, ) emit = _runtime.make_gated_emitter( @@ -1318,15 +1385,29 @@ def _run_export_in_thread( written = write_export( artifact, destination, + force=False, protected_paths=(record.path for record in snapshot.records), ) if snapshot.canceled.is_set(): return + saved_preferences: ExportPreferences | None = None + preference_warning: str | None = None + if snapshot.preferences is not None: + try: + save_export_preferences(snapshot.home, snapshot.preferences) + except ExportPreferencesError as exc: + preference_warning = str(exc) + else: + saved_preferences = snapshot.preferences + if snapshot.canceled.is_set(): + return completed = _ExportCompleted( filename=self._safe_export_filename(written), format=artifact.format, selection=snapshot.selection, record_count=artifact.record_count, + preferences=saved_preferences, + preference_warning=preference_warning, error=None, ) except ExportError as exc: @@ -1335,6 +1416,8 @@ def _run_export_in_thread( format="markdown", selection=snapshot.selection, record_count=0, + preferences=None, + preference_warning=None, error=str(exc), ) except Exception: @@ -1343,6 +1426,8 @@ def _run_export_in_thread( format="markdown", selection=snapshot.selection, record_count=0, + preferences=None, + preference_warning=None, error="export could not be completed", ) if not snapshot.canceled.is_set(): @@ -1386,18 +1471,33 @@ def _apply_export_completed(self, generation: int, event: object) -> None: if not self.is_mounted: return if event.error is not None: + if self._export_dialog is not None: + self._export_dialog.export_failed(event.error) self.notify( event.error, title="Export failed", severity="error", ) return + if event.preferences is not None: + self._export_preferences = event.preferences + dialog = self._export_dialog + if dialog is not None and dialog.phase == "saving": + dialog.export_succeeded() + if self._export_dialog is dialog: + self._export_dialog = None noun = "record" if event.record_count == 1 else "records" self.notify( f"{event.filename} · {event.format} · {event.selection} · {event.record_count} {noun}", title="Export complete", markup=False, ) + if event.preference_warning is not None: + self.notify( + event.preference_warning, + title="Export preferences", + severity="warning", + ) def _has_active_actions(self) -> bool: """Return True if any cancellable in-flight action exists. diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 5d38058f6..c31b7453a 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -5,18 +5,28 @@ import asyncio import collections.abc as cabc import pathlib +import stat import threading import time import typing as t import pytest -from textual.widgets import HelpPanel +from textual.widgets import HelpPanel, Input, Static from agentgrep import identity, record_export from agentgrep.progress import SearchRequestedPayload from agentgrep.records import RecordPosition, SearchRecord -from agentgrep.ui import _runtime -from agentgrep.ui.widgets import FilterCompleted, SearchRequested +from agentgrep.ui import _export_preferences, _runtime +from agentgrep.ui._export_preferences import ( + ExportPreferences, + ExportPreferencesError, + export_preferences_path, + load_export_preferences, + save_export_preferences, +) +from agentgrep.ui.layouts import hud as hud_module +from agentgrep.ui.widgets import ExportDialog, FilterCompleted, SearchRequested +from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker from tests.test_agentgrep_tui_identity import _build_empty_ui_app pytestmark = pytest.mark.tui @@ -34,6 +44,7 @@ def _record( ordinal: int, session_id: str | None = "session-a", source_name: str | None = None, + title: str | None = None, ) -> SearchRecord: """Build one normalized source record with deterministic identities.""" return SearchRecord( @@ -43,6 +54,7 @@ def _record( adapter_id="codex.sessions_jsonl.v1", path=tmp_path / (source_name or f"source-{ordinal}.jsonl"), text=text, + title=title, role="user", timestamp=f"2026-07-12T12:00:{ordinal:02d}Z", model="gpt-test", @@ -79,6 +91,34 @@ async def _wait_for(predicate: t.Callable[[], bool], *, timeout: float = 3.0) -> pytest.fail("timed out waiting for export worker") +def _static_text(dialog: ExportDialog, selector: str) -> str: + """Return the literal plain text last assigned to a dialog ``Static``.""" + static = dialog.query_one(selector, Static) + content = getattr(static, "_Static__content", "") + return getattr(content, "plain", str(content)) + + +async def _open_export_review( + app: t.Any, + pilot: t.Any, + *, + directory: pathlib.Path, + template: str, +) -> tuple[ExportDialog, str]: + """Open the selected-record dialog and advance one draft to review.""" + await pilot.press("e") + await pilot.pause() + assert isinstance(app.screen, ExportDialog) + dialog = app.screen + dialog.query_one("#export-directory", ExportDirectoryPicker).value = str(directory) + template_input = dialog.query_one("#export-template", Input) + template_input.value = template + template_input.focus() + await pilot.press("enter") + await _wait_for(lambda: dialog.phase == "review") + return dialog, _static_text(dialog, "#export-review-filename") + + def _capture_notifications( screen: t.Any, monkeypatch: pytest.MonkeyPatch, @@ -127,47 +167,374 @@ def _change_results(screen: t.Any, change: str, replacement: SearchRecord) -> No @pytest.mark.parametrize("pane", ["_results", "_detail_scroll"], ids=("results", "detail")) @pytest.mark.slow -async def test_export_shortcut_writes_selected_record_and_appears_in_keys( +async def test_export_shortcut_confirms_selected_record_and_appears_in_keys( pane: str, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Plain ``e`` exports from either content pane and appears in key help.""" + """Plain ``e`` stages either content-pane selection before writing.""" monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + config_home = tmp_path / "config" + config_home.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) app = _build_empty_ui_app(tmp_path, monkeypatch) records = ( _record(tmp_path, "first body", ordinal=1), _record(tmp_path, "selected body", ordinal=2), ) export_dir = tmp_path / "data" / "agentgrep" / "exports" + export_dir.mkdir(parents=True) async with app.run_test(size=(120, 30)) as pilot: await pilot.pause() - await _load_records(app.screen, records, selected=0) + hud = app.screen + await _load_records(hud, records, selected=0) await pilot.pause() - app.screen.show_detail(records[1]) + hud.show_detail(records[1]) await pilot.pause() - app.screen._search_input.value = "/keys" - app.screen._search_input.focus() + hud._search_input.value = "/keys" + hud._search_input.focus() await pilot.press("enter") - getattr(app.screen, pane).focus() + getattr(hud, pane).focus() await pilot.pause() - binding = app.screen.active_bindings["e"].binding - assert len(app.screen.query(HelpPanel)) == 1 + binding = hud.active_bindings["e"].binding + assert len(hud.query(HelpPanel)) == 1 assert binding.description == "Export selected" assert binding.show is False + notes = _capture_notifications(hud, monkeypatch) await pilot.press("e") - await _wait_for(lambda: bool(list(export_dir.glob("*.md")))) + await pilot.pause() + + assert isinstance(app.screen, ExportDialog) + dialog = app.screen + assert hud._export_dialog is dialog + assert list(export_dir.glob("*.md")) == [] + hud._results.highlighted = 1 + hud._current_detail_record = records[0] + + await pilot.press("enter", "enter") + await _wait_for(lambda: dialog.phase == "review") + assert _static_text(dialog, "#export-review-directory") == str(export_dir) + await pilot.press("y") + await _wait_for( + lambda: bool(list(export_dir.glob("*.md"))) or dialog.phase == "edit", + ) - exported = next(export_dir.glob("*.md")).read_text(encoding="utf-8") + exports = list(export_dir.glob("*.md")) + assert exports, notes + exported = exports[0].read_text(encoding="utf-8") expected = "first body" if pane == "_results" else "selected body" unexpected = "selected body" if pane == "_results" else "first body" assert expected in exported assert unexpected not in exported +@pytest.mark.slow +async def test_export_preferences_load_before_mount_and_warn_once_path_free( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HUD construction loads once and mount reports a bounded warning.""" + config_home = tmp_path / "config" + config_path = config_home / "agentgrep" / "tui-export.json" + config_path.parent.mkdir(parents=True) + config_path.write_text("{", encoding="utf-8") + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + calls: list[pathlib.Path] = [] + real_load = _export_preferences.load_export_preferences + + def tracked_load(home: pathlib.Path) -> t.Any: + calls.append(home) + return real_load(home) + + notes: list[tuple[tuple[object, ...], dict[str, object]]] = [] + monkeypatch.setattr(_export_preferences, "load_export_preferences", tracked_load) + monkeypatch.setattr( + hud_module.HudLayout, + "notify", + lambda _self, *args, **kwargs: notes.append((args, kwargs)), + ) + + app = _build_empty_ui_app(tmp_path, monkeypatch) + + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + + assert calls == [tmp_path / "home"] + assert notes == [ + ( + ("Export preferences could not be read",), + {"title": "Export preferences", "severity": "warning"}, + ), + ] + assert str(config_path) not in str(notes) + + +@pytest.mark.slow +async def test_confirmed_export_writes_exact_filename_then_preferences( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A reviewed no-clobber artifact precedes preference persistence.""" + config_home = tmp_path / "config" + config_home.mkdir() + export_dir = tmp_path / "Selected" + export_dir.mkdir(mode=0o750) + export_dir.chmod(0o750) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + record = _record( + tmp_path, + "captured body", + ordinal=1, + title="Reviewed [Title]", + ) + order: list[str] = [] + write_calls: list[tuple[pathlib.Path, dict[str, object]]] = [] + real_write = record_export.write_export + real_save = save_export_preferences + + def tracked_write( + artifact: record_export.ExportArtifact, + destination: str | pathlib.Path, + **kwargs: t.Any, + ) -> pathlib.Path: + protected_paths = tuple(kwargs["protected_paths"]) + kwargs["protected_paths"] = protected_paths + order.append("artifact") + write_calls.append((pathlib.Path(destination), dict(kwargs))) + return real_write(artifact, destination, **kwargs) + + def tracked_save(home: pathlib.Path, preferences: ExportPreferences) -> None: + order.append("preferences") + assert write_calls[0][0].exists() + real_save(home, preferences) + + monkeypatch.setattr(record_export, "write_export", tracked_write) + monkeypatch.setattr(hud_module, "save_export_preferences", tracked_save, raising=False) + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + notes = _capture_notifications(hud, monkeypatch) + + _dialog, filename = await _open_export_review( + app, + pilot, + directory=export_dir, + template="reviewed-{title}.md", + ) + destination = export_dir / filename + assert filename == "reviewed-reviewed-title.md" + assert not destination.exists() + assert not export_preferences_path(tmp_path / "home").exists() + + await pilot.press("y") + await _wait_for(destination.exists) + await _wait_for(lambda: app.screen is hud) + + assert order == ["artifact", "preferences"] + assert write_calls == [ + ( + destination, + {"force": False, "protected_paths": (record.path,)}, + ), + ] + assert stat.S_IMODE(export_dir.stat().st_mode) == 0o750 + assert load_export_preferences(tmp_path / "home").preferences == ExportPreferences( + directory=str(export_dir), + filename_template="reviewed-{title}.md", + ) + assert hud._export_dialog is None + assert len(notes) == 1 + assert filename in str(notes[0][0][0]) + + +@pytest.mark.slow +async def test_export_failure_restores_draft_without_saving_preferences( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Artifact failure returns to editing and preserves stored preferences.""" + config_home = tmp_path / "config" + config_home.mkdir() + original_dir = tmp_path / "Original" + original_dir.mkdir() + selected_dir = tmp_path / "Selected" + selected_dir.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + original = ExportPreferences( + directory=str(original_dir), + filename_template="original-{title}.md", + ) + save_export_preferences(tmp_path / "home", original) + record = _record(tmp_path, "body", ordinal=1, title="Retained Draft") + + def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: + message = "export could not be written" + raise record_export.ExportWriteError(message) + + monkeypatch.setattr(record_export, "write_export", fail_write) + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + notes = _capture_notifications(hud, monkeypatch) + dialog, filename = await _open_export_review( + app, + pilot, + directory=selected_dir, + template="retry-{title}.md", + ) + + await pilot.press("y") + await _wait_for(lambda: dialog.phase == "edit") + + assert dialog.is_mounted + assert dialog.query_one("#export-directory", ExportDirectoryPicker).value == str( + selected_dir, + ) + assert dialog.query_one("#export-template", Input).value == "retry-{title}.md" + assert hud._export_dialog is dialog + assert not (selected_dir / filename).exists() + assert load_export_preferences(tmp_path / "home").preferences == original + assert notes[0][1]["severity"] == "error" + + +@pytest.mark.slow +async def test_preference_save_failure_keeps_artifact_success_and_warns( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Config failure cannot turn a completed artifact into export failure.""" + config_home = tmp_path / "config" + config_home.mkdir() + export_dir = tmp_path / "Selected" + export_dir.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + record = _record(tmp_path, "body", ordinal=1, title="Saved Artifact") + + def fail_save(_home: pathlib.Path, _preferences: ExportPreferences) -> t.NoReturn: + message = "Export preferences could not be saved" + raise ExportPreferencesError(message) + + monkeypatch.setattr(hud_module, "save_export_preferences", fail_save, raising=False) + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + notes = _capture_notifications(hud, monkeypatch) + _dialog, filename = await _open_export_review( + app, + pilot, + directory=export_dir, + template="{title}.md", + ) + destination = export_dir / filename + + await pilot.press("y") + await _wait_for(destination.exists) + await _wait_for(lambda: app.screen is hud) + + assert destination.read_text(encoding="utf-8").startswith( + "# agentgrep record export", + ) + assert not export_preferences_path(tmp_path / "home").exists() + assert hud._export_dialog is None + assert len(notes) == 2 + assert any(note[1].get("title") == "Export complete" for note in notes) + warning = next(note for note in notes if note[1].get("severity") == "warning") + assert warning[0][0] == "Export preferences could not be saved" + assert str(tmp_path) not in str(warning) + + +@pytest.mark.slow +async def test_dialog_confirmation_cannot_launch_duplicate_write( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Repeated saving gestures retain one non-supersedable worker.""" + config_home = tmp_path / "config" + config_home.mkdir() + export_dir = tmp_path / "Selected" + export_dir.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + record = _record(tmp_path, "body", ordinal=1, title="Only Once") + started = threading.Event() + release = threading.Event() + calls = 0 + real_write = record_export.write_export + + def slow_write( + artifact: record_export.ExportArtifact, + destination: str | pathlib.Path, + **kwargs: t.Any, + ) -> pathlib.Path: + nonlocal calls + calls += 1 + started.set() + assert release.wait(3) + return real_write(artifact, destination, **kwargs) + + monkeypatch.setattr(record_export, "write_export", slow_write) + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + dialog, filename = await _open_export_review( + app, + pilot, + directory=export_dir, + template="{title}.md", + ) + + await pilot.press("y") + assert await asyncio.to_thread(started.wait, 2) + await pilot.press("y", "enter") + await pilot.pause() + + assert dialog.phase == "saving" + assert calls == 1 + release.set() + await _wait_for(lambda: (export_dir / filename).exists()) + + +@pytest.mark.slow +async def test_unmount_invalidates_and_clears_retained_export_dialog( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HUD teardown drops the modal reference and invalidates completions.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "body", ordinal=1) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + await pilot.press("e") + await pilot.pause() + assert isinstance(app.screen, ExportDialog) + assert hud._export_dialog is app.screen + generation = hud._export_generation + + hud.on_unmount() + + assert hud._export_dialog is None + assert hud._export_generation == generation + 1 + assert hud._export_pending is False + + @pytest.mark.parametrize( "input_attr", ["_search_input", "_filter_input"], @@ -337,6 +704,14 @@ async def test_record_export_writes_markdown_and_preserves_results( ) -> None: """Default and explicit sinks export exactly the selected record.""" monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + saved_preferences: list[ExportPreferences] = [] + monkeypatch.setattr( + hud_module, + "save_export_preferences", + lambda _home, preferences: saved_preferences.append(preferences), + raising=False, + ) app = _build_empty_ui_app(tmp_path, monkeypatch) records = ( _record(tmp_path, "first exact body", ordinal=1), @@ -376,6 +751,8 @@ async def test_record_export_writes_markdown_and_preserves_results( assert str(exported.parent) not in message assert "markdown" in message assert "1 record" in message + assert saved_preferences == [] + assert not export_preferences_path(tmp_path / "home").exists() @pytest.mark.slow @@ -384,6 +761,14 @@ async def test_thread_export_uses_only_selected_observed_thread( monkeypatch: pytest.MonkeyPatch, ) -> None: """Mixed and threadless active results do not contaminate the chosen thread.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + saved_preferences: list[ExportPreferences] = [] + monkeypatch.setattr( + hud_module, + "save_export_preferences", + lambda _home, preferences: saved_preferences.append(preferences), + raising=False, + ) app = _build_empty_ui_app(tmp_path, monkeypatch) records = ( _record(tmp_path, "thread a first", ordinal=1, session_id="session-a"), @@ -410,6 +795,8 @@ async def test_thread_export_uses_only_selected_observed_thread( assert "- Record count: 2" in text assert "- Fidelity: unordered" in text assert "2 records" in str(notes[0][0][0]) + assert saved_preferences == [] + assert not export_preferences_path(tmp_path / "home").exists() @pytest.mark.slow @@ -419,6 +806,14 @@ async def test_thread_export_without_path_uses_private_markdown_sink( ) -> None: """The no-path thread command writes a collision-safe canonical artifact.""" monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + saved_preferences: list[ExportPreferences] = [] + monkeypatch.setattr( + hud_module, + "save_export_preferences", + lambda _home, preferences: saved_preferences.append(preferences), + raising=False, + ) app = _build_empty_ui_app(tmp_path, monkeypatch) records = ( _record(tmp_path, "first", ordinal=1), @@ -441,6 +836,8 @@ async def test_thread_export_without_path_uses_private_markdown_sink( ) assert exported.name in str(notes[0][0][0]) assert str(export_dir) not in str(notes) + assert saved_preferences == [] + assert not export_preferences_path(tmp_path / "home").exists() @pytest.mark.slow @@ -873,6 +1270,8 @@ async def test_stale_export_callback_cannot_clear_live_pending_state( format="markdown", selection="records", record_count=1, + preferences=None, + preference_warning=None, error=None, ), ) @@ -903,6 +1302,8 @@ async def test_export_success_notification_treats_filename_as_literal( format="markdown", selection="records", record_count=1, + preferences=None, + preference_warning=None, error=None, ), ) diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 0ae8de796..de60016fb 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -16,13 +16,12 @@ from textual.pilot import Pilot from textual.widgets import Input, OptionList, Static -import agentgrep.ui.widgets as widgets -from agentgrep.ui import _runtime +from agentgrep.ui import _runtime, widgets from agentgrep.ui._export_preferences import ExportPreferences from agentgrep.ui.widgets import ExportDialog, ExportDraft, ExportIntent from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker -_TIMESTAMP = datetime.datetime(2026, 7, 14, 9, 8, 7) +_TIMESTAMP = datetime.datetime(2026, 7, 14, 9, 8, 7, tzinfo=datetime.UTC) class _ExportDialogHost(App[None]): @@ -309,7 +308,7 @@ async def test_repeated_enter_on_default_no_cannot_save(tmp_path: pathlib.Path) assert seen == [] -@pytest.mark.parametrize("key", ("n", "escape")) +@pytest.mark.parametrize("key", ["n", "escape"]) async def test_no_shortcuts_return_to_edit(tmp_path: pathlib.Path, key: str) -> None: """The explicit No gestures preserve the draft and prior focus.""" seen: list[ExportIntent] = [] diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index 54a380937..affa4dae2 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -15,8 +15,7 @@ from textual.pilot import Pilot from textual.widgets import Input, OptionList -import agentgrep.ui.widgets as widgets -from agentgrep.ui import _runtime +from agentgrep.ui import _runtime, widgets from agentgrep.ui.widgets import directory_popup from agentgrep.ui.widgets.directory_popup import ( DIRECTORY_CANDIDATE_LIMIT, @@ -140,7 +139,7 @@ def __init__(self, entries: list[_InstrumentedEntry]) -> None: self._entries = iter(entries) self.pulls = 0 - def __enter__(self) -> _InstrumentedScandir: + def __enter__(self) -> t.Self: return self def __exit__(self, *_args: object) -> None: @@ -197,11 +196,11 @@ def test_symlink_directories_are_not_candidates(tmp_path: pathlib.Path) -> None: @pytest.mark.parametrize( ("typed", "expected"), - ( + [ ("./choices/a", "./choices/alpha/"), ("~/choices/a", "~/choices/alpha/"), ("{absolute}/a", "{absolute}/alpha/"), - ), + ], ) def test_candidate_labels_are_basenames_and_values_preserve_prefix( typed: str, diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index 374a7443d..3ca8aa2a1 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -78,14 +78,14 @@ def test_default_export_directory_falls_back_under_home( @pytest.mark.parametrize( ("value", "suffix"), - ( + [ ("~", ()), ("~/", ()), ("~//Exports", ("Exports",)), ("~///", ()), ("~/Exports", ("Exports",)), ("~/Exports/agentgrep", ("Exports", "agentgrep")), - ), + ], ) def test_resolve_export_directory_expands_only_current_home( value: str, @@ -137,14 +137,14 @@ def test_default_export_filename_is_frozen_local_ascii() -> None: @pytest.mark.parametrize( "template", - ( + [ "{unknown}.md", "../{title}.md", "{title}/body.md", "{title}", ".md", "CON.md", - ), + ], ) def test_export_filename_rejects_unreviewable_names(template: str) -> None: """Unsafe, unsupported, or extensionless output names are rejected.""" @@ -159,13 +159,13 @@ def test_export_filename_rejects_unreviewable_names(template: str) -> None: @pytest.mark.parametrize( "template", - ( + [ "{{title}}.md", "{title}.md ", "{title}.md.", "{title}\n.md", "\ud800.md", - ), + ], ) def test_export_filename_rejects_ambiguous_or_non_scalar_output(template: str) -> None: """Braces, trailing ambiguity, controls, and surrogates are rejected.""" @@ -257,7 +257,7 @@ def test_missing_export_preferences_return_defaults_without_warning( @pytest.mark.parametrize( "payload", - ( + [ b"{", b" " * (MAX_PREFERENCES_BYTES + 1), b'{"version":2,"directory":"~/Exports","filename_template":"{title}.md"}', @@ -266,7 +266,7 @@ def test_missing_export_preferences_return_defaults_without_warning( b'{"version":1,"directory":"~/Exports","filename_template":2}', b'{"version":1,"directory":"~/Exports","filename_template":"{title}.md","extra":1}', b'{"version":1,"version":1,"directory":"~/Exports","filename_template":"{title}.md"}', - ), + ], ) def test_invalid_export_preferences_return_defaults_with_warning( payload: bytes, From 3a76f4c163c677e510f045532db6964fbe310cb7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:34:44 -0500 Subject: [PATCH 36/71] agentgrep(fix[tui]): Retain saving export why: Durable export writes cannot be truthfully canceled after delegation. Keeping the modal mounted lets worker failure restore the reviewed draft. what: - Ignore Escape and Ctrl-C while the export dialog is saving. - Preserve pre-save cancellation behavior. - Cover blocked failure restoration through the mounted HUD. --- src/agentgrep/ui/widgets/export_dialog.py | 9 ++-- tests/test_ui_export.py | 57 +++++++++++++++++++++++ tests/test_ui_export_dialog.py | 26 +++++++++-- 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index 2f2e130fb..a029e8622 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -266,7 +266,9 @@ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> No @_runtime.pump_only def action_escape(self) -> None: - """Return from review, or cancel from any other phase.""" + """Return from review or cancel before a durable save begins.""" + if self._phase == "saving": + return if self._phase == "review": self._show_edit() return @@ -274,8 +276,9 @@ def action_escape(self) -> None: @_runtime.pump_only def action_cancel(self) -> None: - """Dismiss from every phase without delegating a write.""" - self.dismiss(None) + """Dismiss unless a durable save is already active.""" + if self._phase != "saving": + self.dismiss(None) @_runtime.pump_only def action_review_no(self) -> None: diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index c31b7453a..1ba807a82 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -406,6 +406,63 @@ def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: assert notes[0][1]["severity"] == "error" +@pytest.mark.parametrize("key", ["escape", "ctrl+c"]) +@pytest.mark.slow +async def test_saving_cancel_key_retains_draft_until_write_failure( + key: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Durable-save keys cannot discard the draft needed by a failed write.""" + export_dir = tmp_path / "Selected" + export_dir.mkdir() + record = _record(tmp_path, "body", ordinal=1, title="Retained Draft") + started = threading.Event() + release = threading.Event() + + def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: + started.set() + assert release.wait(3) + message = "export could not be written" + raise record_export.ExportWriteError(message) + + monkeypatch.setattr(record_export, "write_export", fail_write) + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + notes = _capture_notifications(hud, monkeypatch) + dialog, _filename = await _open_export_review( + app, + pilot, + directory=export_dir, + template="retry-{title}.md", + ) + directory = dialog.query_one("#export-directory", ExportDirectoryPicker).value + template = dialog.query_one("#export-template", Input).value + + await pilot.press("y") + assert await asyncio.to_thread(started.wait, 2) + await pilot.press(key) + await pilot.pause() + active_during_save = app.screen is dialog + retained_during_save = hud._export_dialog is dialog + phase_during_save = dialog.phase + release.set() + await _wait_for(lambda: bool(notes)) + + assert active_during_save + assert retained_during_save + assert phase_during_save == "saving" + assert app.screen is dialog + assert hud._export_dialog is dialog + assert dialog.phase == "edit" + assert dialog.query_one("#export-directory", ExportDirectoryPicker).value == directory + assert dialog.query_one("#export-template", Input).value == template + + @pytest.mark.slow async def test_preference_save_failure_keeps_artifact_success_and_warns( tmp_path: pathlib.Path, diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index de60016fb..67343405f 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -341,13 +341,33 @@ async def test_y_invokes_once_and_enters_saving(tmp_path: pathlib.Path) -> None: ) -async def test_ctrl_c_dismisses_even_while_saving(tmp_path: pathlib.Path) -> None: - """Ctrl-C closes the modal after write delegation as it does while editing.""" +@pytest.mark.parametrize("key", ["escape", "ctrl+c"]) +async def test_saving_ignores_cancel_keys(tmp_path: pathlib.Path, key: str) -> None: + """A delegated durable write keeps its modal until worker completion.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) async with app.run_test(size=(60, 16)) as pilot: await _open_review(app, pilot) await pilot.press("y") - assert _dialog(app).phase == "saving" + dialog = _dialog(app) + assert dialog.phase == "saving" + + await pilot.press(key) + await pilot.pause() + + assert app.screen is dialog + assert dialog.phase == "saving" + + +@pytest.mark.parametrize("phase", ["edit", "review"]) +async def test_ctrl_c_dismisses_before_saving( + tmp_path: pathlib.Path, + phase: str, +) -> None: + """Ctrl-C still cancels while the dialog has no durable worker.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + if phase == "review": + await _open_review(app, pilot) await pilot.press("ctrl+c") await _wait_for(pilot, lambda: app.dismissed is None) From 0ee0b94142925cff68e5da42ec9eccf0a7b951ed Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:35:29 -0500 Subject: [PATCH 37/71] agentgrep(fix[tui]): Preload export settings why: Layout construction can run on Textual's message pump when F2 first opens the HUD. Preference file reads belong before the app session starts. what: - Load one export-preference snapshot at the lazy app factory boundary. - Inject the snapshot through UiContext with a pure direct-test fallback. - Guard F2 construction, warning, and CLI cold-path behavior. --- src/agentgrep/ui/_context.py | 15 +++++++-------- src/agentgrep/ui/app.py | 25 +++++++++++++++++++++---- src/agentgrep/ui/layouts/hud.py | 13 +++++++++---- tests/test_ui_export.py | 9 ++++++--- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/agentgrep/ui/_context.py b/src/agentgrep/ui/_context.py index 516f4472e..7bbcbfcef 100644 --- a/src/agentgrep/ui/_context.py +++ b/src/agentgrep/ui/_context.py @@ -1,7 +1,8 @@ """Shared dependency context injected into pluggable TUI layouts (ADR 0013). The App shell owns the session-fixed collaborators — the engine seam, the launch -query, and the cooperative-cancel control — and passes them to whichever +query, the cooperative-cancel control, and the export-preference snapshot — and +passes them to whichever :class:`~agentgrep.ui.layouts._base.LayoutScreen` it mounts as one frozen ``UiContext``. A layout reaches the engine only through ``invoker`` (ADR 0012 RW-1), so it stays engine-agnostic and is constructable in a test with a fake @@ -23,7 +24,7 @@ SearchScope, SearchScopeProvenance, ) - from agentgrep.ui._export_preferences import ExportPreferences + from agentgrep.ui._export_preferences import ExportPreferencesLoad from agentgrep.ui._history import HistoryEntry from agentgrep.ui._seams import SearchInvoker @@ -67,10 +68,9 @@ class UiContext: Preloaded query-history snapshot for layouts that expose recall. history_disabled : bool, optional Whether persistent query history is disabled for this session. - export_preferences : ExportPreferences | None, optional - Export settings preloaded before Textual starts. - export_preferences_warning : str | None, optional - Path-free warning produced while preloading export settings. + export_preferences : ExportPreferencesLoad | None, optional + Preference snapshot loaded once by the app factory before Textual owns + the session. ``None`` lets direct layout tests use pure defaults. """ home: pathlib.Path @@ -84,5 +84,4 @@ class UiContext: initial_search_text: str | None = None history: tuple[HistoryEntry, ...] = () history_disabled: bool = False - export_preferences: ExportPreferences | None = None - export_preferences_warning: str | None = None + export_preferences: ExportPreferencesLoad | None = None diff --git a/src/agentgrep/ui/app.py b/src/agentgrep/ui/app.py index 76d62204c..658f8f41b 100644 --- a/src/agentgrep/ui/app.py +++ b/src/agentgrep/ui/app.py @@ -14,7 +14,7 @@ import pathlib import typing as t -from agentgrep.ui import _export_preferences, _history, preferences, registry +from agentgrep.ui import _history, preferences, registry from agentgrep.ui._context import UiContext if t.TYPE_CHECKING: @@ -26,6 +26,7 @@ SearchScope, SearchScopeProvenance, ) + from agentgrep.ui._export_preferences import ExportPreferencesLoad __all__ = ["build_streaming_ui_app", "run_ui"] @@ -34,6 +35,24 @@ class UiQueryTooLongError(ValueError): """Raised when a launch expression cannot fit in the TUI input.""" +def _load_export_preferences(home: pathlib.Path) -> ExportPreferencesLoad: + """Load preferences without warming the root CLI import path. + + Parameters + ---------- + home : pathlib.Path + User home directory used by preference path defaults. + + Returns + ------- + ExportPreferencesLoad + One session-fixed preference snapshot. + """ + from agentgrep.ui._export_preferences import load_export_preferences + + return load_export_preferences(home) + + def run_ui( home: pathlib.Path, query: SearchQuery, @@ -199,7 +218,6 @@ def build_streaming_ui_app( resolved_base_conversation_limit = ( query.conversation_limit if resolved_base_effort == "targeted" else None ) - export_preferences_load = _export_preferences.load_export_preferences(home) ctx = UiContext( home=home, invoker=EngineSearchInvoker(home), @@ -212,8 +230,7 @@ def build_streaming_ui_app( initial_search_text=initial_search_text, history=history, history_disabled=history_disabled, - export_preferences=export_preferences_load.preferences, - export_preferences_warning=export_preferences_load.warning, + export_preferences=_load_export_preferences(home), ) config_path = preferences.theme_config_path(home=home) selected_theme = preferences.load_theme_name(config_path) diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index a973d4ac8..8f3ebaf92 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -237,10 +237,15 @@ def __init__(self, ctx: UiContext, workflow: Workflow) -> None: self._history_path = _history.history_path(self.home) self._history = list(ctx.history) self._last_recorded_text = self._history[0].text if self._history else "" - self._export_preferences = ctx.export_preferences or ExportPreferences( - directory=str(default_export_directory(self.home)), - ) - self._export_preferences_warning = ctx.export_preferences_warning + loaded_export_preferences = ctx.export_preferences + if loaded_export_preferences is None: + self._export_preferences = ExportPreferences( + directory=str(default_export_directory(self.home)), + ) + self._export_preferences_warning = None + else: + self._export_preferences = loaded_export_preferences.preferences + self._export_preferences_warning = loaded_export_preferences.warning # Export is a non-supersedable durable action. The pump prepares one # point-in-time result snapshot in bounded chunks, then transfers sole # ownership to a thread worker. A second request remains blocked until diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 1ba807a82..7b130252b 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -16,7 +16,7 @@ from agentgrep import identity, record_export from agentgrep.progress import SearchRequestedPayload from agentgrep.records import RecordPosition, SearchRecord -from agentgrep.ui import _export_preferences, _runtime +from agentgrep.ui import _export_preferences, _runtime, app as ui_app from agentgrep.ui._export_preferences import ( ExportPreferences, ExportPreferencesError, @@ -236,7 +236,7 @@ async def test_export_preferences_load_before_mount_and_warn_once_path_free( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """HUD construction loads once and mount reports a bounded warning.""" + """The factory loads once before mount and F2 reuses the session snapshot.""" config_home = tmp_path / "config" config_path = config_home / "agentgrep" / "tui-export.json" config_path.parent.mkdir(parents=True) @@ -250,7 +250,7 @@ def tracked_load(home: pathlib.Path) -> t.Any: return real_load(home) notes: list[tuple[tuple[object, ...], dict[str, object]]] = [] - monkeypatch.setattr(_export_preferences, "load_export_preferences", tracked_load) + monkeypatch.setattr(ui_app, "_load_export_preferences", tracked_load) monkeypatch.setattr( hud_module.HudLayout, "notify", @@ -258,9 +258,12 @@ def tracked_load(home: pathlib.Path) -> t.Any: ) app = _build_empty_ui_app(tmp_path, monkeypatch) + assert calls == [tmp_path / "home"] async with app.run_test(size=(120, 30)) as pilot: await pilot.pause() + await pilot.press("f2", "f2") + await pilot.pause() assert calls == [tmp_path / "home"] assert notes == [ From f63bae5541bdbd748132c00fac735d090c498108 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:37:55 -0500 Subject: [PATCH 38/71] agentgrep(docs[export]): Explain TUI save flow why: The selected-record shortcut now reviews a remembered destination before writing. The public export contract needs to distinguish that interactive exception from automatic private names and headless surfaces. what: - Document the exact TUI preview, confirmation, and no-clobber flow. - Keep automatic canonical-ID names and CLI/MCP preference isolation clear. - Add documentation contract tests for the reviewed filename exception. --- CHANGES | 17 +++++---- docs/cli/export.md | 23 ++++++++++++ docs/dev/adr/0017-portable-record-export.md | 25 ++++++++++--- tests/test_export_docs.py | 40 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/CHANGES b/CHANGES index adbc34ee7..74fd83cbd 100644 --- a/CHANGES +++ b/CHANGES @@ -79,12 +79,17 @@ agentgrep can now turn selected search records into deterministic NDJSON or human-readable Markdown without changing the underlying histories. The CLI exports matching records to standard output or a chosen file, the HUD exports one selected record or its observed thread, and MCP returns a bounded inline -artifact for existing search refs. In the HUD, `e` exports the selected record -from the results list or detail pane and remains ordinary text in inputs. - -Machine clients must opt in before an MCP export includes prompt or history -bodies, and file output refuses accidental replacement. See {ref}`the export -guide ` for formats, selection, and privacy details. +artifact for existing search refs. In the HUD, `e` captures the selected record +from the results list or detail pane, then opens a compact destination review; +it remains ordinary text in inputs. + +The review remembers its directory and filename template, previews a +filesystem-safe local timestamp with a bounded title, and keeps the draft when +No is selected. Saving refuses accidental replacement. Automatic private +exports keep canonical-ID filenames, while CLI and MCP remain isolated from +the TUI preference. Machine clients must opt in before an MCP export includes +prompt or history bodies. See {ref}`the export guide ` for formats, +selection, and privacy details. ## agentgrep 0.1.0a50 (2026-08-09) diff --git a/docs/cli/export.md b/docs/cli/export.md index 8cc94dd29..7098490ff 100644 --- a/docs/cli/export.md +++ b/docs/cli/export.md @@ -13,6 +13,29 @@ search. Terms are combined with AND semantics, and the default scope is `prompts`. The default limit is `100`; set `--limit` to any value from `1` through `1000`. +## TUI reviewed save + +Press `e` while an exact selected record has focus in the HUD results or +detail pane. One compact dialog remembers the export directory and filename +template in TUI-private user configuration. It starts with +`{date} {time} - {title}.md`; directory completion lists existing child +directories and accepts a choice with the arrow keys and Tab. + +The preview freezes local time when the dialog opens. The date and time render +as the filesystem-safe `YYYY-MM-DD HH-MM-SS`, and the title token uses a +bounded normalized form of the record title without reading its body or source +path. Submitting the draft shows the directory and exact filename separately. +The confirmation starts on **No**; No returns to editing with both values +intact. + +Save writes only the reviewed explicit no-clobber destination. If that name +already exists, agentgrep returns to the same draft instead of replacing the +file or silently choosing another name. Automatic private exports requested by +the HUD slash commands keep their canonical-ID names. CLI and MCP do not +consume the TUI preference: the CLI still uses standard output or an explicit +`--output` path, and MCP still returns a bounded inline artifact without local +filesystem authority. + ## Examples Export matching prompt records as NDJSON to standard output: diff --git a/docs/dev/adr/0017-portable-record-export.md b/docs/dev/adr/0017-portable-record-export.md index c070982b5..18cee599e 100644 --- a/docs/dev/adr/0017-portable-record-export.md +++ b/docs/dev/adr/0017-portable-record-export.md @@ -101,6 +101,15 @@ Identity, rendering, and disk work run off the Textual message pump. Only one accepted write may be pending, and a changed result snapshot cancels an observed-thread export instead of writing a mixed view. +Pressing `e` in a content pane captures the exact selected record and opens one +staged TUI dialog. The dialog remembers its reviewed directory and filename +template in a small TUI-private file under the platform user configuration +directory. It previews a filename, validates an existing directory, then shows +the directory and exact basename separately with **No** selected. No returns to +the retained draft; Save writes the explicit no-clobber destination. CLI and +MCP do not consume this preference or gain any additional filesystem +authority. + The MCP {tooliconl}`export_records` tool accepts one to 20 unique `agref1:` search refs and no query, cursor, or local destination. It resolves refs with the same position-aware and historical compatibility semantics as @@ -132,10 +141,18 @@ only matching records; a TUI explicit path protects the selected snapshot's sources. The TUI-owned default export directory is mode `0700`, and artifact and -temporary files are mode `0600`. Private filenames derive only from canonical -IDs and structural metadata, never prompt text, a title, or a source path, and -collisions allocate a new name rather than replacing an older export. Errors -remain path-free. +temporary files are mode `0600`. Automatic private filenames derive only from +canonical IDs and structural metadata, never prompt text, a title, or a source +path, and collisions allocate a new name rather than replacing an older +export. Errors remain path-free. + +The reviewed `e` dialog is a narrow exception to that automatic filename +policy. Its default template combines a filesystem-safe local timestamp with +a bounded normalized `SearchRecord.title`; slugging never reads the record +body or source path. The user sees the exact basename before accepting an +explicit no-clobber destination, so the writer neither replaces the file nor +changes the reviewed name to resolve a collision. A missing title uses a +stable, non-sensitive record label. ### Deferred tiers diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 4d759fb84..6f9beb3bb 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -94,6 +94,28 @@ def test_export_tui_docs_define_private_off_pump_workflow() -> None: assert not missing, f"docs/tui/index.md is missing {missing!r}" +def test_export_guide_defines_reviewed_tui_destination() -> None: + """The export guide explains the remembered, exact TUI save flow.""" + guide = _read_text("docs/cli/export.md") + required = ( + "Press `e`", + "exact selected record", + "remembers the export directory and filename template", + "user configuration", + "`{date} {time} - {title}.md`", + "local time", + "filesystem-safe", + "`YYYY-MM-DD HH-MM-SS`", + "exact filename", + "No returns to editing", + "no-clobber", + "CLI and MCP do not consume", + ) + + missing = _missing_terms(guide, required) + assert not missing, f"docs/cli/export.md is missing {missing!r}" + + def test_export_mcp_docs_define_bounded_inline_contract() -> None: """The MCP guide distinguishes selection from discovery and local writes.""" tools = _read_text("docs/mcp/tools.md") @@ -276,6 +298,24 @@ def test_export_adr_pins_writer_and_deferred_tiers() -> None: assert not missing, f"export ADR is missing {missing!r}" +def test_export_adr_pins_interactive_filename_exception() -> None: + """The ADR keeps reviewed TUI names separate from automatic private names.""" + adr = _read_text("docs/dev/adr/0017-portable-record-export.md") + required = ( + "narrow exception", + "bounded normalized `SearchRecord.title`", + "filesystem-safe local timestamp", + "exact basename", + "explicit no-clobber destination", + "automatic private filenames", + "derive only from canonical IDs", + "CLI and MCP do not consume", + ) + + missing = _missing_terms(adr, required) + assert not missing, f"export ADR is missing {missing!r}" + + def test_export_console_examples_are_individually_copyable() -> None: """Every export console block contains exactly one shell command.""" guide = _read_text("docs/cli/export.md") From e069aef103a39981df4a47ccdd3723c7bcbaf0a3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:39:15 -0500 Subject: [PATCH 39/71] agentgrep(docs[export]): Narrow MCP authority why: MCP export is inline and accepts no destination, but describing it as lacking all local filesystem authority overstates the public contract. what: - State that MCP accepts no local destination. - Limit the authority claim to filesystem writes. --- docs/cli/export.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/cli/export.md b/docs/cli/export.md index 7098490ff..4aa8f2053 100644 --- a/docs/cli/export.md +++ b/docs/cli/export.md @@ -33,8 +33,8 @@ already exists, agentgrep returns to the same draft instead of replacing the file or silently choosing another name. Automatic private exports requested by the HUD slash commands keep their canonical-ID names. CLI and MCP do not consume the TUI preference: the CLI still uses standard output or an explicit -`--output` path, and MCP still returns a bounded inline artifact without local -filesystem authority. +`--output` path, and MCP still returns a bounded inline artifact, accepts no +local destination, and gains no filesystem write authority. ## Examples From de7b4f991ec5a86be10133a6c0b2160265fff59d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:39:33 -0500 Subject: [PATCH 40/71] agentgrep(docs[export]): Clarify first use why: The default filename template applies only before a successful export; subsequent dialogs should be understood to start from saved preferences. what: - Mark the documented template as a first-use default. - Explain when remembered directory and template values replace defaults. --- docs/cli/export.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/cli/export.md b/docs/cli/export.md index 4aa8f2053..9113ee5e7 100644 --- a/docs/cli/export.md +++ b/docs/cli/export.md @@ -17,9 +17,11 @@ through `1000`. Press `e` while an exact selected record has focus in the HUD results or detail pane. One compact dialog remembers the export directory and filename -template in TUI-private user configuration. It starts with -`{date} {time} - {title}.md`; directory completion lists existing child -directories and accepts a choice with the arrow keys and Tab. +template in TUI-private user configuration. On first use, the filename +template is `{date} {time} - {title}.md`; after a successful save, the +remembered directory and template replace the first-use defaults. Directory +completion lists existing child directories and accepts a choice with the +arrow keys and Tab. The preview freezes local time when the dialog opens. The date and time render as the filesystem-safe `YYYY-MM-DD HH-MM-SS`, and the title token uses a From 23822cf82b6470e30d8b077c466de2d72030783a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:40:07 -0500 Subject: [PATCH 41/71] agentgrep(test[export]): Scope docs contracts why: Whole-file keyword checks can pass when a required claim drifts into an unrelated section and make ordinary prose wording unnecessarily rigid. what: - Extract only a named Markdown section with a small partition helper. - Scope TUI interaction claims to the guide's reviewed-save section. - Split ADR claims between surface defaults and durable file output. --- tests/test_export_docs.py | 78 ++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 6f9beb3bb..c2123fe7c 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -29,6 +29,15 @@ def _missing_terms(text: str, required: tuple[str, ...]) -> tuple[str, ...]: ) +def _markdown_section(text: str, heading: str) -> str: + """Return the body below one named Markdown heading of the same level.""" + marker = f"{heading}\n" + _, found, remainder = text.partition(marker) + assert found, f"missing Markdown section: {heading}" + level = heading.split(maxsplit=1)[0] + return remainder.partition(f"\n{level} ")[0] + + def test_export_docs_are_indexed() -> None: """The CLI guide and ADR are reachable through their public indexes.""" cli_index = _read_text("docs/cli/index.md") @@ -97,23 +106,31 @@ def test_export_tui_docs_define_private_off_pump_workflow() -> None: def test_export_guide_defines_reviewed_tui_destination() -> None: """The export guide explains the remembered, exact TUI save flow.""" guide = _read_text("docs/cli/export.md") - required = ( - "Press `e`", - "exact selected record", - "remembers the export directory and filename template", - "user configuration", + section = _markdown_section(guide, "## TUI reviewed save") + normalized = re.sub(r"\s+", " ", section).casefold() + + for literal in ( + "`e`", "`{date} {time} - {title}.md`", - "local time", - "filesystem-safe", "`YYYY-MM-DD HH-MM-SS`", - "exact filename", - "No returns to editing", "no-clobber", - "CLI and MCP do not consume", + ): + assert literal in section + assert re.search( + r"exact selected record.*remembers the export directory and filename template", + normalized, + ) + assert re.search( + r"first use.*after a successful save.*remembered directory and template", + normalized, + ) + assert re.search(r"local time.*filesystem-safe", normalized) + assert re.search(r"\bno returns to editing\b", normalized) + assert re.search(r"cli and mcp do not consume the tui preference", normalized) + assert re.search( + r"mcp.*accepts no local destination.*no filesystem write authority", + normalized, ) - - missing = _missing_terms(guide, required) - assert not missing, f"docs/cli/export.md is missing {missing!r}" def test_export_mcp_docs_define_bounded_inline_contract() -> None: @@ -301,19 +318,30 @@ def test_export_adr_pins_writer_and_deferred_tiers() -> None: def test_export_adr_pins_interactive_filename_exception() -> None: """The ADR keeps reviewed TUI names separate from automatic private names.""" adr = _read_text("docs/dev/adr/0017-portable-record-export.md") - required = ( - "narrow exception", - "bounded normalized `SearchRecord.title`", - "filesystem-safe local timestamp", - "exact basename", - "explicit no-clobber destination", - "automatic private filenames", - "derive only from canonical IDs", - "CLI and MCP do not consume", + surface = re.sub( + r"\s+", + " ", + _markdown_section(adr, "### Surface defaults"), + ).casefold() + durable_section = _markdown_section(adr, "### Durable file output") + durable = re.sub(r"\s+", " ", durable_section).casefold() + + assert "`e`" in surface + assert re.search(r"exact selected record.*remembers.*directory and filename template", surface) + assert re.search(r"exact basename.*\bno returns.*explicit no-clobber destination", surface) + assert re.search(r"cli and mcp do not consume this preference", surface) + + assert "`SearchRecord.title`" in durable_section + assert re.search( + r"automatic private filenames derive only from canonical ids", + durable, ) - - missing = _missing_terms(adr, required) - assert not missing, f"export ADR is missing {missing!r}" + assert re.search( + r"narrow exception.*filesystem-safe local timestamp.*bounded normalized", + durable, + ) + assert re.search(r"exact basename.*explicit no-clobber destination", durable) + assert re.search(r"never reads the record body or source path", durable) def test_export_console_examples_are_individually_copyable() -> None: From e821f4767e84892b56d83da042c2b520d9749282 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:41:07 -0500 Subject: [PATCH 42/71] agentgrep(docs[export]): Clarify persistence why: An artifact can save even when its preference update fails. Tying remembered defaults to any successful export overstates the persistence contract. what: - Say remembered values replace defaults only after preferences persist. - Pin that distinction in the reviewed-save documentation contract. --- docs/cli/export.md | 8 ++++---- tests/test_export_docs.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/cli/export.md b/docs/cli/export.md index 9113ee5e7..0cd6a4ea2 100644 --- a/docs/cli/export.md +++ b/docs/cli/export.md @@ -18,10 +18,10 @@ through `1000`. Press `e` while an exact selected record has focus in the HUD results or detail pane. One compact dialog remembers the export directory and filename template in TUI-private user configuration. On first use, the filename -template is `{date} {time} - {title}.md`; after a successful save, the -remembered directory and template replace the first-use defaults. Directory -completion lists existing child directories and accepts a choice with the -arrow keys and Tab. +template is `{date} {time} - {title}.md`; after the preferences are saved +successfully, the remembered directory and template replace the first-use +defaults. Directory completion lists existing child directories and accepts a +choice with the arrow keys and Tab. The preview freezes local time when the dialog opens. The date and time render as the filesystem-safe `YYYY-MM-DD HH-MM-SS`, and the title token uses a diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index c2123fe7c..c1e677072 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -121,7 +121,7 @@ def test_export_guide_defines_reviewed_tui_destination() -> None: normalized, ) assert re.search( - r"first use.*after a successful save.*remembered directory and template", + r"first use.*after the preferences are saved successfully.*remembered directory and template", normalized, ) assert re.search(r"local time.*filesystem-safe", normalized) From 81e0db8af07e76ccade0fa168f597ec07e2850f4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:41:52 -0500 Subject: [PATCH 43/71] agentgrep(fix[tui]): Validate template grammar why: Preference persistence compiled templates with an arbitrary sample title, so grammar validity incorrectly depended on one rendered filename's byte size. what: - Validate the exact token grammar and literal filename structure directly. - Keep the 180-byte bound on filenames rendered for the selected title. - Cover long safe templates independently of compiled filename limits. --- src/agentgrep/ui/_export_preferences.py | 49 ++++++++++++++++++------- tests/test_export_docs.py | 3 +- tests/test_ui_export_preferences.py | 26 +++++++++++++ 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index 85f5fbe2c..d777da9c5 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -27,6 +27,7 @@ _DIRECTORY_ERROR = "Export directory is invalid" _FILENAME_ERROR = "Export filename is invalid" _SCHEMA_KEYS = frozenset({"version", "directory", "filename_template"}) +_TEMPLATE_TOKENS = frozenset({"date", "time", "title"}) _CONFIG_DIRECTORY_NAME = "agentgrep" _PREFERENCES_FILENAME = "tui-export.json" @@ -176,6 +177,37 @@ def _validate_filename(filename: str) -> None: raise ExportPreferencesError(_FILENAME_ERROR) +def _validate_filename_template(template: str) -> None: + """Validate the template grammar without compiling a selected title.""" + if not isinstance(template, str) or len(template) > MAX_TEMPLATE_CHARS: + raise ExportPreferencesError(_FILENAME_ERROR) + if any(unicodedata.category(character) in {"Cc", "Cs"} for character in template): + raise ExportPreferencesError(_FILENAME_ERROR) + if "/" in template or "\\" in template: + raise ExportPreferencesError(_FILENAME_ERROR) + if not template.endswith(".md") or not template.removesuffix(".md"): + raise ExportPreferencesError(_FILENAME_ERROR) + if template.endswith((" ", ".")) or ntpath.isreserved(template): + raise ExportPreferencesError(_FILENAME_ERROR) + try: + template.encode("utf-8") + except UnicodeEncodeError: + raise ExportPreferencesError(_FILENAME_ERROR) from None + + cursor = 0 + while cursor < len(template): + opening = template.find("{", cursor) + closing = template.find("}", cursor) + if closing != -1 and (opening == -1 or closing < opening): + raise ExportPreferencesError(_FILENAME_ERROR) + if opening == -1: + return + closing = template.find("}", opening + 1) + if closing == -1 or template[opening + 1 : closing] not in _TEMPLATE_TOKENS: + raise ExportPreferencesError(_FILENAME_ERROR) + cursor = closing + 1 + + def render_export_filename( template: str, title: str, @@ -205,8 +237,7 @@ def render_export_filename( ExportPreferencesError If the template or compiled basename is unsafe or outside its bounds. """ - if not isinstance(template, str) or len(template) > MAX_TEMPLATE_CHARS: - raise ExportPreferencesError(_FILENAME_ERROR) + _validate_filename_template(template) slug = _slug(title) or _slug(fallback_title) if not slug: raise ExportPreferencesError(_FILENAME_ERROR) @@ -249,12 +280,7 @@ def _parse_preferences(payload: bytes) -> ExportPreferences: raise ValueError if not isinstance(directory, str) or not isinstance(filename_template, str): raise TypeError - render_export_filename( - filename_template, - title="Title", - fallback_title="record", - timestamp=datetime.datetime(2000, 1, 1, tzinfo=datetime.UTC), - ) + _validate_filename_template(filename_template) return ExportPreferences(directory=directory, filename_template=filename_template) @@ -372,12 +398,7 @@ def _serialize_preferences(preferences: ExportPreferences) -> bytes: str, ): raise ExportPreferencesError(_PREFERENCES_SAVE_ERROR) - render_export_filename( - preferences.filename_template, - title="Title", - fallback_title="record", - timestamp=datetime.datetime(2000, 1, 1, tzinfo=datetime.UTC), - ) + _validate_filename_template(preferences.filename_template) payload = json.dumps( { "version": 1, diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index c1e677072..7b00f1534 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -121,7 +121,8 @@ def test_export_guide_defines_reviewed_tui_destination() -> None: normalized, ) assert re.search( - r"first use.*after the preferences are saved successfully.*remembered directory and template", + r"first use.*after the preferences are saved successfully.*" + r"remembered directory and template", normalized, ) assert re.search(r"local time.*filesystem-safe", normalized) diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index 3ca8aa2a1..d91ec2168 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -321,6 +321,32 @@ def test_export_preferences_round_trip_unicode_with_private_modes( } +def test_export_preferences_validate_template_without_sample_title( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Persistence validates grammar without applying a compiled filename bound.""" + config_home = tmp_path / "config" + config_home.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + template = "x" * MAX_FILENAME_BYTES + "-{title}.md" + preferences = ExportPreferences( + directory="~/Exports", + filename_template=template, + ) + + save_export_preferences(tmp_path / "home", preferences) + + assert load_export_preferences(tmp_path / "home").preferences == preferences + with pytest.raises(ExportPreferencesError, match=r"^Export filename is invalid$"): + render_export_filename( + template, + title="x", + fallback_title="record", + timestamp=datetime.datetime(2026, 7, 14).astimezone(), + ) + + def test_save_export_preferences_retries_short_writes( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From 562ae6041af2463f85020e6162cbca5c989192a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:43:45 -0500 Subject: [PATCH 44/71] agentgrep(fix[tui]): Use session home why: Directory completion delegated tilde expansion to process-global account lookup instead of the home already owned by the TUI session. what: - Pass the session home from the export dialog through the picker worker. - Resolve only current-user tilde syntax with the shared safe resolver. - Cover session-home expansion and other-user rejection without expanduser. --- src/agentgrep/ui/widgets/directory_popup.py | 26 ++++- src/agentgrep/ui/widgets/export_dialog.py | 1 + tests/test_ui_export_directory_popup.py | 109 +++++++++++++++++--- 3 files changed, 117 insertions(+), 19 deletions(-) diff --git a/src/agentgrep/ui/widgets/directory_popup.py b/src/agentgrep/ui/widgets/directory_popup.py index 8a210f864..5bb145bf0 100644 --- a/src/agentgrep/ui/widgets/directory_popup.py +++ b/src/agentgrep/ui/widgets/directory_popup.py @@ -19,6 +19,10 @@ from textual.worker import NoActiveWorker, get_current_worker from agentgrep.ui import _runtime +from agentgrep.ui._export_preferences import ( + ExportPreferencesError, + resolve_export_directory, +) __all__ = [ "DIRECTORY_CANDIDATE_LIMIT", @@ -60,7 +64,10 @@ def _active_worker_cancelled() -> bool: return False -def _split_directory_prefix(value: str) -> tuple[pathlib.Path, str, str]: +def _split_directory_prefix( + value: str, + home: pathlib.Path, +) -> tuple[pathlib.Path, str, str]: """Return scan parent, typed parent prefix, and partial basename.""" if value.endswith(os.sep): display_parent = value @@ -68,13 +75,14 @@ def _split_directory_prefix(value: str) -> tuple[pathlib.Path, str, str]: else: _parent, prefix = os.path.split(value) display_parent = value[: -len(prefix)] if prefix else value - scan_parent = pathlib.Path(display_parent or ".").expanduser() + scan_parent = resolve_export_directory(display_parent or ".", home) return scan_parent, display_parent, prefix def _enumerate_directory_candidates( value: str, *, + home: pathlib.Path, candidate_limit: int, scan_limit: int, ) -> _DirectoryCandidates: @@ -101,10 +109,10 @@ def _enumerate_directory_candidates( bounded_scan_limit = max(scan_limit, 0) if not value or not bounded_candidate_limit or not bounded_scan_limit: return _DirectoryCandidates((), False) - scan_parent, display_parent, prefix = _split_directory_prefix(value) matches: list[DirectoryCandidate] = [] truncated = False try: + scan_parent, display_parent, prefix = _split_directory_prefix(value, home) with os.scandir(scan_parent) as entries: for index, entry in enumerate( itertools.islice(entries, bounded_scan_limit + 1), @@ -128,7 +136,7 @@ def _enumerate_directory_candidates( label=entry.name, ), ) - except OSError, RuntimeError, ValueError: + except ExportPreferencesError, OSError, RuntimeError, ValueError: return _DirectoryCandidates((), False) matches.sort(key=lambda candidate: candidate.label.casefold()) return _DirectoryCandidates(tuple(matches[:bounded_candidate_limit]), truncated) @@ -214,9 +222,16 @@ class ExportDirectoryPicker(Vertical): } """ - def __init__(self, value: str, *, id: str | None = None) -> None: # noqa: A002 + def __init__( + self, + value: str, + home: pathlib.Path, + *, + id: str | None = None, # noqa: A002 + ) -> None: super().__init__(id=id) self._input = _DirectoryPathInput(self, value=value) + self._home = home self._popup = DirectoryCompletionPopup() self._candidate_generation = 0 self._candidate_values: tuple[DirectoryCandidate, ...] = () @@ -298,6 +313,7 @@ def _enumerate_in_thread( """Enumerate one immutable snapshot away from the pump.""" event = _enumerate_directory_candidates( value, + home=self._home, candidate_limit=DIRECTORY_CANDIDATE_LIMIT, scan_limit=DIRECTORY_SCAN_LIMIT, ) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index a029e8622..b99761302 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -198,6 +198,7 @@ def compose(self) -> ComposeResult: yield Static("Directory", classes="export-label") yield ExportDirectoryPicker( value=self._initial_preferences.directory, + home=self._home, id="export-directory", ) yield Static("Template", classes="export-label") diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index affa4dae2..d05cd6029 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -36,9 +36,13 @@ class _DirectoryPopupHost(App[None]): #filename { height: 3; } """ + def __init__(self, home: pathlib.Path) -> None: + super().__init__() + self._home = home + def compose(self) -> ComposeResult: """Compose the owning picker and the next focus target.""" - yield ExportDirectoryPicker(value="", id="directory") + yield ExportDirectoryPicker(value="", home=self._home, id="directory") yield Input(placeholder="Filename", id="filename") def on_mount(self) -> None: @@ -99,12 +103,23 @@ async def test_directory_enumeration_waits_for_inactivity( calls: list[tuple[str, float]] = [] original = directory_popup._enumerate_directory_candidates - def observed(value: str, *, candidate_limit: int, scan_limit: int) -> object: + def observed( + value: str, + *, + home: pathlib.Path, + candidate_limit: int, + scan_limit: int, + ) -> object: calls.append((value, time.monotonic())) - return original(value, candidate_limit=candidate_limit, scan_limit=scan_limit) + return original( + value, + home=home, + candidate_limit=candidate_limit, + scan_limit=scan_limit, + ) monkeypatch.setattr(directory_popup, "_enumerate_directory_candidates", observed) - app = _DirectoryPopupHost() + app = _DirectoryPopupHost(tmp_path / "home") async with app.run_test(size=(60, 16)) as pilot: picker = app.query_one(ExportDirectoryPicker) picker.value = f"{root}{os.sep}a" @@ -168,6 +183,7 @@ def test_directory_scan_has_raw_bound_and_truncation_sentinel( result = directory_popup._enumerate_directory_candidates( "./", + home=pathlib.Path("/session-home"), candidate_limit=DIRECTORY_CANDIDATE_LIMIT, scan_limit=DIRECTORY_SCAN_LIMIT, ) @@ -187,6 +203,48 @@ def test_symlink_directories_are_not_candidates(tmp_path: pathlib.Path) -> None: result = directory_popup._enumerate_directory_candidates( f"{tmp_path}{os.sep}a", + home=tmp_path / "home", + candidate_limit=DIRECTORY_CANDIDATE_LIMIT, + scan_limit=DIRECTORY_SCAN_LIMIT, + ) + + assert result.values == () + + +def test_tilde_completion_uses_session_home_without_expanduser( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Completion resolves current-user tilde syntax against the TUI session home.""" + session_home = tmp_path / "session-home" + choices = session_home / "choices" + choices.mkdir(parents=True) + (choices / "alpha").mkdir() + process_home = tmp_path / "process-home" + process_home.mkdir() + monkeypatch.setenv("HOME", str(process_home)) + unexpected_expanduser = "process-global expanduser must not run" + + def reject_expanduser(_path: pathlib.Path) -> t.NoReturn: + raise AssertionError(unexpected_expanduser) + + monkeypatch.setattr(pathlib.Path, "expanduser", reject_expanduser) + + result = directory_popup._enumerate_directory_candidates( + "~/choices/a", + home=session_home, + candidate_limit=DIRECTORY_CANDIDATE_LIMIT, + scan_limit=DIRECTORY_SCAN_LIMIT, + ) + + assert result.values == (DirectoryCandidate(value="~/choices/alpha/", label="alpha"),) + + +def test_other_user_tilde_completion_is_rejected(tmp_path: pathlib.Path) -> None: + """Completion never delegates other-user tilde syntax to account lookup.""" + result = directory_popup._enumerate_directory_candidates( + "~other/choices/a", + home=tmp_path / "session-home", candidate_limit=DIRECTORY_CANDIDATE_LIMIT, scan_limit=DIRECTORY_SCAN_LIMIT, ) @@ -219,6 +277,7 @@ def test_candidate_labels_are_basenames_and_values_preserve_prefix( result = directory_popup._enumerate_directory_candidates( typed, + home=tmp_path, candidate_limit=DIRECTORY_CANDIDATE_LIMIT, scan_limit=DIRECTORY_SCAN_LIMIT, ) @@ -245,7 +304,7 @@ def observed_scandir(path: str | os.PathLike[str]) -> t.Any: monkeypatch.setattr(directory_popup.os, "scandir", observed_scandir) pump_thread = threading.get_ident() - app = _DirectoryPopupHost() + app = _DirectoryPopupHost(tmp_path / "home") async with app.run_test(size=(60, 16)) as pilot: picker = app.query_one(ExportDirectoryPicker) popup = _popup(app) @@ -266,7 +325,7 @@ async def test_up_down_wrap_and_right_accepts_only_at_end(tmp_path: pathlib.Path root.mkdir() for name in ("alpha", "beta"): (root / name).mkdir() - app = _DirectoryPopupHost() + app = _DirectoryPopupHost(tmp_path / "home") async with app.run_test(size=(60, 16)) as pilot: picker = app.query_one(ExportDirectoryPicker) field = picker.query_one(Input) @@ -295,7 +354,7 @@ async def test_tab_accepts_only_when_open_then_traverses(tmp_path: pathlib.Path) root = tmp_path / "choices" root.mkdir() (root / "child").mkdir() - app = _DirectoryPopupHost() + app = _DirectoryPopupHost(tmp_path / "home") async with app.run_test(size=(60, 16)) as pilot: picker = app.query_one(ExportDirectoryPicker) filename = app.query_one("#filename", Input) @@ -321,13 +380,24 @@ async def test_late_directory_result_cannot_reopen_after_tab( release = threading.Event() original = directory_popup._enumerate_directory_candidates - def delayed(value: str, *, candidate_limit: int, scan_limit: int) -> object: + def delayed( + value: str, + *, + home: pathlib.Path, + candidate_limit: int, + scan_limit: int, + ) -> object: started.set() release.wait(1) - return original(value, candidate_limit=candidate_limit, scan_limit=scan_limit) + return original( + value, + home=home, + candidate_limit=candidate_limit, + scan_limit=scan_limit, + ) monkeypatch.setattr(directory_popup, "_enumerate_directory_candidates", delayed) - app = _DirectoryPopupHost() + app = _DirectoryPopupHost(tmp_path / "home") async with app.run_test(size=(60, 16)) as pilot: picker = app.query_one(ExportDirectoryPicker) picker.value = f"{tmp_path}{os.sep}a" @@ -350,13 +420,24 @@ async def test_unmount_cancels_worker_and_invalidates_generation( release = threading.Event() original = directory_popup._enumerate_directory_candidates - def delayed(value: str, *, candidate_limit: int, scan_limit: int) -> object: + def delayed( + value: str, + *, + home: pathlib.Path, + candidate_limit: int, + scan_limit: int, + ) -> object: started.set() release.wait(1) - return original(value, candidate_limit=candidate_limit, scan_limit=scan_limit) + return original( + value, + home=home, + candidate_limit=candidate_limit, + scan_limit=scan_limit, + ) monkeypatch.setattr(directory_popup, "_enumerate_directory_candidates", delayed) - app = _DirectoryPopupHost() + app = _DirectoryPopupHost(tmp_path / "home") async with app.run_test(size=(60, 16)) as pilot: picker = app.query_one(ExportDirectoryPicker) popup = _popup(app) @@ -380,7 +461,7 @@ def delayed(value: str, *, candidate_limit: int, scan_limit: int) -> object: async def test_popup_stays_within_picker_at_compact_geometry(tmp_path: pathlib.Path) -> None: """The borderless overlay never exceeds its owning picker at 60 by 16.""" (tmp_path / "alpha").mkdir() - app = _DirectoryPopupHost() + app = _DirectoryPopupHost(tmp_path / "home") async with app.run_test(size=(60, 16)) as pilot: picker = app.query_one(ExportDirectoryPicker) popup = _popup(app) From 62613bf4872c61d57a2cca1f56ab302bd53b44e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:44:34 -0500 Subject: [PATCH 45/71] agentgrep(fix[tui]): Serialize directory scans why: Canceling a Textual worker cannot stop a thread already blocked in scandir, so rapid edits could overlap filesystem enumerations. what: - Keep one active enumeration and coalesce newer input into one latest slot. - Launch the queued scan only after the blocked filesystem call returns. - Preserve debounce, generation, focus, and unmount invalidation contracts. --- src/agentgrep/ui/widgets/directory_popup.py | 49 ++++++++++---- tests/test_ui_export_directory_popup.py | 71 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/src/agentgrep/ui/widgets/directory_popup.py b/src/agentgrep/ui/widgets/directory_popup.py index 5bb145bf0..09cac2b3f 100644 --- a/src/agentgrep/ui/widgets/directory_popup.py +++ b/src/agentgrep/ui/widgets/directory_popup.py @@ -237,6 +237,8 @@ def __init__( self._candidate_values: tuple[DirectoryCandidate, ...] = () self._debounce_timer: Timer | None = None self._pending_value = value + self._enumeration_running = False + self._queued_enumeration: tuple[int, str] | None = None @property def value(self) -> str: @@ -267,7 +269,7 @@ def on_input_changed(self, event: Input.Changed) -> None: @_runtime.pump_only def on_unmount(self) -> None: """Cancel all completion work before the picker leaves the DOM.""" - self._invalidate_completion() + self._invalidate_completion(cancel_worker=True) @_runtime.pump_only def _schedule_enumeration(self) -> None: @@ -289,6 +291,15 @@ def _debounce_elapsed(self) -> None: generation = self._candidate_generation if not value or not self.is_mounted or not self._input.has_focus: return + if self._enumeration_running: + self._queued_enumeration = (generation, value) + return + self._launch_enumeration(generation, value) + + @_runtime.pump_only + def _launch_enumeration(self, generation: int, value: str) -> None: + """Start one enumeration after every earlier scan has returned.""" + self._enumeration_running = True emit = _runtime.make_gated_emitter( self.app.call_from_thread, self._apply_candidates, @@ -323,29 +334,43 @@ def _enumerate_in_thread( @_runtime.pump_only def _apply_candidates(self, generation: int, event: object) -> None: """Apply only a current focused picker's bounded worker result.""" - if ( + self._enumeration_running = False + stale = ( generation != self._candidate_generation or not self.is_mounted or not self._input.has_focus or not isinstance(event, _DirectoryCandidates) + ) + if not stale: + self._candidate_values = event.values + options: list[Option] = [Option(candidate.label) for candidate in event.values] + if event.truncated: + options.append(Option(_TRUNCATION_LABEL, disabled=True)) + self._popup.set_options(options) + self._popup.highlighted = 0 if event.values else None + self._popup.display = bool(options) + + queued = self._queued_enumeration + self._queued_enumeration = None + if ( + queued is not None + and queued[0] == self._candidate_generation + and self.is_mounted + and self._input.has_focus ): - return - self._candidate_values = event.values - options: list[Option] = [Option(candidate.label) for candidate in event.values] - if event.truncated: - options.append(Option(_TRUNCATION_LABEL, disabled=True)) - self._popup.set_options(options) - self._popup.highlighted = 0 if event.values else None - self._popup.display = bool(options) + self._launch_enumeration(*queued) @_runtime.pump_only - def _invalidate_completion(self) -> None: + def _invalidate_completion(self, *, cancel_worker: bool = False) -> None: """Stop pending work, advance generation, and clear completion chrome.""" if self._debounce_timer is not None: self._debounce_timer.stop() self._debounce_timer = None self._candidate_generation += 1 - self.workers.cancel_group(self, _DIRECTORY_WORKER_GROUP) + self._queued_enumeration = None + if cancel_worker: + self.workers.cancel_group(self, _DIRECTORY_WORKER_GROUP) + self._enumeration_running = False self._candidate_values = () self._popup.clear_options() self._popup.display = False diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index d05cd6029..fb4151bab 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -134,6 +134,77 @@ def observed( assert calls[0][1] - changed_at >= DIRECTORY_COMPLETION_DEBOUNCE - 0.02 +async def test_directory_enumeration_coalesces_while_worker_is_blocked( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Rapid edits queue only the latest scan behind one blocked enumeration.""" + root = tmp_path / "choices" + root.mkdir() + (root / "alpha").mkdir() + first_started = threading.Event() + release_first = threading.Event() + values: list[str] = [] + active = 0 + maximum_active = 0 + lock = threading.Lock() + original_enumerate = directory_popup._enumerate_directory_candidates + original_scandir = os.scandir + + def observed_enumerate( + value: str, + *, + home: pathlib.Path, + candidate_limit: int, + scan_limit: int, + ) -> object: + values.append(value) + return original_enumerate( + value, + home=home, + candidate_limit=candidate_limit, + scan_limit=scan_limit, + ) + + def blocked_scandir(path: str | os.PathLike[str]) -> t.Any: + nonlocal active, maximum_active + with lock: + active += 1 + maximum_active = max(maximum_active, active) + first = not first_started.is_set() + if first: + first_started.set() + try: + if first: + release_first.wait(2) + return original_scandir(path) + finally: + with lock: + active -= 1 + + monkeypatch.setattr( + directory_popup, + "_enumerate_directory_candidates", + observed_enumerate, + ) + monkeypatch.setattr(directory_popup.os, "scandir", blocked_scandir) + app = _DirectoryPopupHost(tmp_path / "home") + async with app.run_test(size=(60, 16)) as pilot: + picker = app.query_one(ExportDirectoryPicker) + picker.value = f"{root}{os.sep}a" + await _wait_for(pilot, first_started.is_set) + picker.value = f"{root}{os.sep}al" + await pilot.pause(DIRECTORY_COMPLETION_DEBOUNCE / 2) + latest = f"{root}{os.sep}alp" + picker.value = latest + await pilot.pause(DIRECTORY_COMPLETION_DEBOUNCE + 0.1) + release_first.set() + await _wait_for(pilot, lambda: len(values) >= 2) + + assert values == [f"{root}{os.sep}a", latest] + assert maximum_active == 1 + + class _InstrumentedEntry: """A scandir row that records directory probes.""" From 8b1446e5fa4d62a161bf31b5141bcc407f612098 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:45:56 -0500 Subject: [PATCH 46/71] agentgrep(fix[tui]): Bound preference reads why: Opening an untrusted preference path with buffered I/O could block startup on a FIFO and accepted non-regular file types before parsing. what: - Open preference files nonblocking without following the final symlink. - Require a regular fstat result before one bounded read loop. - Cover FIFO latency, path-free fallback, and the exact 16 KiB boundary. --- src/agentgrep/ui/_export_preferences.py | 29 +++++++++-- tests/test_ui_export_preferences.py | 69 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index d777da9c5..495b7ca63 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -14,6 +14,7 @@ import os import pathlib import secrets +import stat import typing as t import unicodedata @@ -285,11 +286,31 @@ def _parse_preferences(payload: bytes) -> ExportPreferences: def _read_preferences(path: pathlib.Path) -> bytes: - """Read one payload without crossing the preference byte limit.""" - with path.open("rb") as handle: - if os.fstat(handle.fileno()).st_size > MAX_PREFERENCES_BYTES: + """Read one regular payload without blocking or crossing the byte limit.""" + nonblocking = getattr(os, "O_NONBLOCK", 0) + no_follow = getattr(os, "O_NOFOLLOW", 0) + if not nonblocking or not no_follow: + raise OSError + flags = os.O_RDONLY | nonblocking | no_follow | getattr(os, "O_CLOEXEC", 0) + file_descriptor = os.open(path, flags) + try: + status = os.fstat(file_descriptor) + if not stat.S_ISREG(status.st_mode) or status.st_size > MAX_PREFERENCES_BYTES: raise ValueError - return handle.read(MAX_PREFERENCES_BYTES) + chunks: list[bytes] = [] + remaining = MAX_PREFERENCES_BYTES + 1 + while remaining: + chunk = os.read(file_descriptor, remaining) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + payload = b"".join(chunks) + if len(payload) > MAX_PREFERENCES_BYTES: + raise ValueError + return payload + finally: + _close_quietly(file_descriptor) def load_export_preferences(home: pathlib.Path) -> ExportPreferencesLoad: diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index d91ec2168..a4681d4a4 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -7,6 +7,8 @@ import os import pathlib import stat +import threading +import time import typing as t import pytest @@ -19,6 +21,7 @@ MAX_TEMPLATE_CHARS, ExportPreferences, ExportPreferencesError, + ExportPreferencesLoad, default_export_directory, export_preferences_path, load_export_preferences, @@ -290,6 +293,72 @@ def test_invalid_export_preferences_return_defaults_with_warning( assert loaded.warning == "Export preferences could not be read" +def test_export_preferences_fifo_returns_promptly_with_path_free_warning( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Startup preference loading never blocks while opening a FIFO.""" + config_home = tmp_path / "config" + data_home = tmp_path / "data" + config_path = config_home / "agentgrep" / "tui-export.json" + config_path.parent.mkdir(parents=True) + os.mkfifo(config_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + loaded: list[object] = [] + + def load() -> None: + loaded.append(load_export_preferences(tmp_path / "home")) + + thread = threading.Thread(target=load, daemon=True) + started_at = time.monotonic() + thread.start() + thread.join(0.25) + blocked = thread.is_alive() + if blocked: + writer = os.open(config_path, os.O_WRONLY | os.O_NONBLOCK) + os.close(writer) + thread.join(1) + + assert not blocked + assert time.monotonic() - started_at < 0.25 + assert len(loaded) == 1 + result = t.cast("ExportPreferencesLoad", loaded[0]) + assert result.preferences == ExportPreferences( + directory=str(data_home / "agentgrep" / "exports"), + ) + assert result.warning == "Export preferences could not be read" + assert str(tmp_path) not in result.warning + + +def test_export_preferences_accept_exact_byte_limit( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A regular preference payload may occupy exactly the documented bound.""" + config_home = tmp_path / "config" + config_path = config_home / "agentgrep" / "tui-export.json" + config_path.parent.mkdir(parents=True) + preferences = ExportPreferences( + directory="~/Exports", + filename_template="{title}.md", + ) + payload = json.dumps( + { + "version": 1, + "directory": preferences.directory, + "filename_template": preferences.filename_template, + }, + separators=(",", ":"), + ).encode() + config_path.write_bytes(payload.ljust(MAX_PREFERENCES_BYTES, b" ")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + + loaded = load_export_preferences(tmp_path / "home") + + assert loaded == ExportPreferencesLoad(preferences) + + def test_export_preferences_round_trip_unicode_with_private_modes( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From 063cbed8e11c315b3033b0b87a57abc4ed729738 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:47:08 -0500 Subject: [PATCH 47/71] agentgrep(fix[tui]): Reject format controls why: Unicode format controls can reorder or hide characters in a reviewed basename without appearing as ordinary control characters. what: - Reject category Cf in template literals and compiled filenames. - Cover zero-width and bidi override controls with focused regressions. --- src/agentgrep/ui/_export_preferences.py | 9 +++++++-- tests/test_ui_export_preferences.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index 495b7ca63..bfe3de14b 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -29,6 +29,7 @@ _FILENAME_ERROR = "Export filename is invalid" _SCHEMA_KEYS = frozenset({"version", "directory", "filename_template"}) _TEMPLATE_TOKENS = frozenset({"date", "time", "title"}) +_UNSAFE_FILENAME_CATEGORIES = frozenset({"Cc", "Cf", "Cs"}) _CONFIG_DIRECTORY_NAME = "agentgrep" _PREFERENCES_FILENAME = "tui-export.json" @@ -160,7 +161,9 @@ def _validate_filename(filename: str) -> None: """Reject an unsafe or unreviewable compiled filename.""" if "{" in filename or "}" in filename: raise ExportPreferencesError(_FILENAME_ERROR) - if any(unicodedata.category(character) in {"Cc", "Cs"} for character in filename): + if any( + unicodedata.category(character) in _UNSAFE_FILENAME_CATEGORIES for character in filename + ): raise ExportPreferencesError(_FILENAME_ERROR) if "/" in filename or "\\" in filename: raise ExportPreferencesError(_FILENAME_ERROR) @@ -182,7 +185,9 @@ def _validate_filename_template(template: str) -> None: """Validate the template grammar without compiling a selected title.""" if not isinstance(template, str) or len(template) > MAX_TEMPLATE_CHARS: raise ExportPreferencesError(_FILENAME_ERROR) - if any(unicodedata.category(character) in {"Cc", "Cs"} for character in template): + if any( + unicodedata.category(character) in _UNSAFE_FILENAME_CATEGORIES for character in template + ): raise ExportPreferencesError(_FILENAME_ERROR) if "/" in template or "\\" in template: raise ExportPreferencesError(_FILENAME_ERROR) diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index a4681d4a4..983b7ed17 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -181,6 +181,27 @@ def test_export_filename_rejects_ambiguous_or_non_scalar_output(template: str) - ) +@pytest.mark.parametrize( + "template", + ( + "safe\u200b-{title}.md", + "safe\u202e-{title}.md", + ), +) +def test_export_filename_rejects_invisible_format_controls(template: str) -> None: + """Zero-width and bidi controls cannot survive in a reviewed basename.""" + with pytest.raises( + ExportPreferencesError, + match=r"^Export filename is invalid$", + ): + render_export_filename( + template, + title="Title", + fallback_title="codex-prompt", + timestamp=datetime.datetime(2026, 7, 14).astimezone(), + ) + + def test_export_filename_normalizes_unicode_and_uses_sanitized_fallback() -> None: """Unicode letters survive while separators collapse and empty titles fall back.""" when = datetime.datetime(2026, 7, 14).astimezone() From bbd8b0f17cc799e15c5dadcf8a5152ba2fbe8662 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:48:12 -0500 Subject: [PATCH 48/71] agentgrep(fix[tui]): Create default directory why: A clean first-use session remembered an app-owned export path that the review validator required to exist, so the default flow could not proceed. what: - Create only the computed private default through the descriptor-safe walker. - Keep arbitrary missing and symlinked directories validation-only and rejected. - Prove clean-home export, 0700 mode, and path safety in Pilot regressions. --- src/agentgrep/record_export.py | 6 ++ src/agentgrep/ui/widgets/export_dialog.py | 9 +++ tests/test_ui_export.py | 3 +- tests/test_ui_export_dialog.py | 75 ++++++++++++++++++++++- tests/test_ui_export_preferences.py | 4 +- 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/agentgrep/record_export.py b/src/agentgrep/record_export.py index 1ba0b9026..89387b58b 100644 --- a/src/agentgrep/record_export.py +++ b/src/agentgrep/record_export.py @@ -412,6 +412,12 @@ def _open_directory(path: pathlib.Path, *, create_private: bool) -> int: return current_fd +def _ensure_private_directory(path: pathlib.Path) -> None: + """Create one private directory tree through the descriptor-safe walker.""" + directory_fd = _open_directory(path, create_private=True) + _close_quietly(directory_fd) + + def _destination_stat(directory_fd: int, name: str) -> os.stat_result | None: """Inspect a final component without following it.""" try: diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index b99761302..25ba5eff1 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -22,6 +22,7 @@ from agentgrep.ui._export_preferences import ( ExportPreferences, ExportPreferencesError, + default_export_directory, render_export_filename, resolve_export_directory, ) @@ -79,6 +80,8 @@ def _validate_export_draft( home: pathlib.Path, ) -> _ValidationResult: """Validate one immutable draft away from the Textual pump.""" + from agentgrep.record_export import ExportError, _ensure_private_directory + try: filename = render_export_filename( draft.filename_template, @@ -90,6 +93,12 @@ def _validate_export_draft( except ExportPreferencesError: return _ValidationResult(error=_DIRECTORY_ERROR) + if directory == default_export_directory(home): + try: + _ensure_private_directory(directory) + except ExportError: + return _ValidationResult(error=_DIRECTORY_UNAVAILABLE_ERROR) + try: if directory.is_symlink() or not directory.is_dir(): return _ValidationResult(error=_DIRECTORY_UNAVAILABLE_ERROR) diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 7b130252b..2520c2613 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -183,7 +183,7 @@ async def test_export_shortcut_confirms_selected_record_and_appears_in_keys( _record(tmp_path, "selected body", ordinal=2), ) export_dir = tmp_path / "data" / "agentgrep" / "exports" - export_dir.mkdir(parents=True) + assert not export_dir.exists() async with app.run_test(size=(120, 30)) as pilot: await pilot.pause() hud = app.screen @@ -224,6 +224,7 @@ async def test_export_shortcut_confirms_selected_record_and_appears_in_keys( exports = list(export_dir.glob("*.md")) assert exports, notes + assert stat.S_IMODE(export_dir.stat().st_mode) == 0o700 exported = exports[0].read_text(encoding="utf-8") expected = "first body" if pane == "_results" else "selected body" unexpected = "selected body" if pane == "_results" else "first body" diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 67343405f..9984da2e6 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -7,6 +7,7 @@ import datetime import os import pathlib +import stat import threading import time import typing as t @@ -17,7 +18,7 @@ from textual.widgets import Input, OptionList, Static from agentgrep.ui import _runtime, widgets -from agentgrep.ui._export_preferences import ExportPreferences +from agentgrep.ui._export_preferences import ExportPreferences, default_export_directory from agentgrep.ui.widgets import ExportDialog, ExportDraft, ExportIntent from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker @@ -241,6 +242,78 @@ def observed_access(path: os.PathLike[str], mode: int) -> bool: assert all(thread_id != pump_thread for thread_id in access_threads) +async def test_first_use_default_directory_is_created_privately( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A clean session can review the app-owned default without pre-creating it.""" + home = tmp_path / "home" + data_home = tmp_path / "data" + data_home.mkdir() + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + directory = default_export_directory(home) + app = _ExportDialogHost( + home, + lambda _intent: True, + directory=str(directory), + ) + async with app.run_test(size=(60, 16)) as pilot: + assert not directory.exists() + await pilot.press("tab", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase != "validating") + + assert _dialog(app).phase == "review" + assert directory.is_dir() + assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + + +async def test_missing_arbitrary_directory_is_not_created(tmp_path: pathlib.Path) -> None: + """Validation never creates a missing user-entered directory tree.""" + directory = tmp_path / "missing" / "arbitrary" + app = _ExportDialogHost( + tmp_path / "home", + lambda _intent: True, + directory=str(directory), + ) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.press("tab", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase != "validating") + + assert _dialog(app).phase == "edit" + assert _text(app, "#export-error") == "Export directory is unavailable" + assert not directory.exists() + + +async def test_default_directory_creation_rejects_symlinked_app_path( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The default-directory exception cannot traverse an app-path symlink.""" + home = tmp_path / "home" + data_home = tmp_path / "data" + outside = tmp_path / "outside" + data_home.mkdir() + outside.mkdir() + sentinel = outside / "keep.txt" + sentinel.write_text("keep", encoding="utf-8") + (data_home / "agentgrep").symlink_to(outside, target_is_directory=True) + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + directory = default_export_directory(home) + app = _ExportDialogHost( + home, + lambda _intent: True, + directory=str(directory), + ) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.press("tab", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase != "validating") + + assert _dialog(app).phase == "edit" + assert _text(app, "#export-error") == "Export directory is unavailable" + assert sentinel.read_text(encoding="utf-8") == "keep" + assert {entry.name for entry in outside.iterdir()} == {"keep.txt"} + + async def test_existing_exact_destination_prevents_review(tmp_path: pathlib.Path) -> None: """Validation refuses the exact previewed basename instead of clobbering it.""" destination = tmp_path / "2026-07-14 09-08-07 - machine-readable-title.md" diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index 983b7ed17..4261f9307 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -183,10 +183,10 @@ def test_export_filename_rejects_ambiguous_or_non_scalar_output(template: str) - @pytest.mark.parametrize( "template", - ( + [ "safe\u200b-{title}.md", "safe\u202e-{title}.md", - ), + ], ) def test_export_filename_rejects_invisible_format_controls(template: str) -> None: """Zero-width and bidi controls cannot survive in a reviewed basename.""" From d988ae5ad094cf47f67d01080725e69a6e24681c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:49:21 -0500 Subject: [PATCH 49/71] agentgrep(fix[tui]): Show accepted save why: Pressing y accepted the durable action while the disabled confirmation list still visibly highlighted No. what: - Move the review highlight to Save before disabling confirmation. - Pin the visible saving state in the existing single-write Pilot test. --- src/agentgrep/ui/widgets/export_dialog.py | 1 + tests/test_ui_export_dialog.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index 25ba5eff1..5f808f386 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -462,5 +462,6 @@ def _confirm(self) -> None: return self._phase = "saving" confirm = self.query_one("#export-confirm", OptionList) + confirm.highlighted = 1 confirm.disabled = True self.query_one("#export-review-status", Static).update(Content("Saving…")) diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 9984da2e6..8cb535b3d 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -404,6 +404,9 @@ async def test_y_invokes_once_and_enters_saving(tmp_path: pathlib.Path) -> None: await pilot.press("y", "y", "enter") assert _dialog(app).phase == "saving" + confirm = app.screen.query_one("#export-confirm", OptionList) + assert confirm.highlighted == 1 + assert confirm.disabled is True assert len(seen) == 1 assert seen[0] == ExportIntent( destination=(tmp_path / "2026-07-14 09-08-07 - machine-readable-title.md"), From c797d20baf098ee534eab6fad285d34329ed9f44 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:50:29 -0500 Subject: [PATCH 50/71] agentgrep(fix[tui]): Scroll compact export why: The inline template error fell below a 30 by 10 tmux viewport, leaving a focused editor with no visible explanation. what: - Make edit and review stages vertically scrollable only when space requires it. - Scroll inline errors into view without adding pump-side work. - Cover feedback and stage reachability at 40x12, 30x10, and 60x16. --- src/agentgrep/ui/widgets/export_dialog.py | 28 ++++++++----- tests/test_ui_export_dialog.py | 49 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index 5f808f386..ae12df009 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -12,7 +12,7 @@ from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Vertical +from textual.containers import Vertical, VerticalScroll from textual.content import Content from textual.screen import ModalScreen from textual.widgets import Input, OptionList, Static @@ -203,7 +203,7 @@ def phase(self) -> ExportPhase: def compose(self) -> ComposeResult: """Compose one quiet edit/review flow with literal output surfaces.""" with Vertical(id="export-dialog"): - with Vertical(id="export-edit"): + with VerticalScroll(id="export-edit"): yield Static("Directory", classes="export-label") yield ExportDirectoryPicker( value=self._initial_preferences.directory, @@ -224,7 +224,7 @@ def compose(self) -> ComposeResult: id="export-edit-footer", markup=False, ) - with Vertical(id="export-review"): + with VerticalScroll(id="export-review"): yield Static("Directory", classes="export-label") yield Static("", id="export-review-directory", markup=False) yield Static("Filename", classes="export-label") @@ -327,12 +327,20 @@ def _refresh_preview(self) -> bool: ) except ExportPreferencesError as error: self.query_one("#export-preview", Static).update(Content("")) - self.query_one("#export-error", Static).update(Content(str(error))) + self._update_error(str(error)) return False self.query_one("#export-preview", Static).update(Content(filename)) - self.query_one("#export-error", Static).update(Content("")) + self._update_error("") return True + @_runtime.pump_only + def _update_error(self, message: str) -> None: + """Update inline feedback and expose it in a compact scrolling edit stage.""" + error = self.query_one("#export-error", Static) + error.update(Content(message)) + if message: + error.scroll_visible(animate=False, immediate=True) + @_runtime.pump_only def _start_validation(self) -> None: """Snapshot the draft and launch one exclusive validator worker.""" @@ -414,8 +422,8 @@ def _show_edit(self, error: str | None = None) -> None: """Restore the retained edit stage and its prior focus.""" self._phase = "edit" self._intent = None - edit = self.query_one("#export-edit", Vertical) - review = self.query_one("#export-review", Vertical) + edit = self.query_one("#export-edit", VerticalScroll) + review = self.query_one("#export-review", VerticalScroll) edit.display = True review.display = False picker = self.query_one("#export-directory", ExportDirectoryPicker) @@ -427,7 +435,7 @@ def _show_edit(self, error: str | None = None) -> None: ) self._refresh_preview() if error is not None: - self.query_one("#export-error", Static).update(Content(error)) + self._update_error(error) if self._edit_focus == "directory": picker.focus_input() else: @@ -437,8 +445,8 @@ def _show_edit(self, error: str | None = None) -> None: def _show_review(self, intent: ExportIntent) -> None: """Show the literal directory and exact basename with No selected.""" self._phase = "review" - self.query_one("#export-edit", Vertical).display = False - self.query_one("#export-review", Vertical).display = True + self.query_one("#export-edit", VerticalScroll).display = False + self.query_one("#export-review", VerticalScroll).display = True self.query_one("#export-review-directory", Static).update( Content(intent.preferences.directory), ) diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 8cb535b3d..920ea0579 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -14,6 +14,7 @@ import pytest from textual.app import App +from textual.containers import VerticalScroll from textual.pilot import Pilot from textual.widgets import Input, OptionList, Static @@ -499,8 +500,56 @@ async def test_dialog_fits_compact_terminal_without_horizontal_scroll( async with app.run_test(size=(60, 16)) as pilot: await pilot.pause() dialog_body = app.screen.query_one("#export-dialog") + edit = app.screen.query_one("#export-edit", VerticalScroll) assert dialog_body.region.width <= 60 assert dialog_body.region.height <= 16 + assert edit.show_vertical_scrollbar is False await _open_review(app, pilot) + review = app.screen.query_one("#export-review", VerticalScroll) assert dialog_body.region.width <= 60 assert dialog_body.region.height <= 16 + assert review.show_vertical_scrollbar is False + + +@pytest.mark.parametrize("size", ((40, 12), (30, 10))) +async def test_invalid_template_error_is_visible_in_small_terminal( + size: tuple[int, int], + tmp_path: pathlib.Path, +) -> None: + """Inline edit feedback remains inside a narrow tmux viewport.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=size) as pilot: + await pilot.press("tab") + template = app.screen.query_one("#export-template", Input) + template.value = "{unknown}.md" + await pilot.press("enter") + await pilot.pause() + error = app.screen.query_one("#export-error", Static) + + assert _text(app, "#export-error") == "Export filename is invalid" + assert error.region.y >= 0 + assert error.region.bottom <= size[1] + assert template.has_focus + + +@pytest.mark.parametrize("size", ((40, 12), (30, 10))) +async def test_review_and_edit_are_reachable_in_small_terminal( + size: tuple[int, int], + tmp_path: pathlib.Path, +) -> None: + """The confirmation and retained editor remain keyboard-reachable when compact.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=size) as pilot: + await _open_review(app, pilot) + confirm = app.screen.query_one("#export-confirm", OptionList) + + assert confirm.has_focus + assert confirm.region.y >= 0 + assert confirm.region.bottom <= size[1] + + await pilot.press("n") + template = app.screen.query_one("#export-template", Input) + assert _dialog(app).phase == "edit" + assert template.has_focus + assert template.region.y >= 0 + assert template.region.bottom <= size[1] From 1b7455b3b974442051cbf79bab6564af3efe55c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:50:56 -0500 Subject: [PATCH 51/71] agentgrep(docs[tui]): Explain export review why: The TUI guide still described e as an immediate private export and blurred its remembered review state with one-shot slash commands. what: - Document the reviewed directory, template, exact filename, and No path. - Keep explicit and private-default slash-command behavior distinct. - Scope the documentation contract to the Export section. --- docs/tui/index.md | 16 +++++++++++----- tests/test_export_docs.py | 15 ++++++++++++--- tests/test_ui_export_dialog.py | 4 ++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/tui/index.md b/docs/tui/index.md index 1e85c5960..0d054d38e 100644 --- a/docs/tui/index.md +++ b/docs/tui/index.md @@ -292,17 +292,23 @@ it. If a paste comes back stale, that is where to look first. ## Export -The HUD offers two pi-like slash commands: +The HUD offers two pi-like, one-shot slash commands: - `/export [PATH]` exports exactly the selected record. - `/export-thread [PATH]` exports the selected record's observed thread from the current result set after the in-list filter. A record without a canonical thread handle cannot be exported as a thread. -Press `e` with the results list or detail pane focused to export the selected -record to the private Markdown destination. Use `/export PATH` when an explicit -destination is needed. The contextual `/keys` panel lists the shortcut without -adding it to the compact footer. +Press `e` with the results list or detail pane focused to review the exact +selected record before saving it. The dialog starts from the remembered +explicit directory and filename template, previews the exact filename, and +keeps both values when No returns to editing. Save writes that reviewed new +destination and remembers the values only after its preferences persist. The +contextual `/keys` panel lists the shortcut without adding it to the compact +footer. + +The slash commands do not read or change those remembered values. Supplying +`PATH` gives that invocation an explicit one-shot destination. Without `PATH`, both commands write a collision-free Markdown artifact to agentgrep's private export directory. Its root follows `XDG_DATA_HOME`; when diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 7b00f1534..955ba8bd0 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -80,6 +80,8 @@ def test_export_cli_docs_define_defaults_and_safe_sinks() -> None: def test_export_tui_docs_define_private_off_pump_workflow() -> None: """The TUI guide covers both pi-like commands and safe notifications.""" tui = _read_text("docs/tui/index.md") + section = _markdown_section(tui, "## Export") + normalized = re.sub(r"\s+", " ", section).casefold() required = ( "`/export [PATH]`", "`/export-thread [PATH]`", @@ -99,8 +101,15 @@ def test_export_tui_docs_define_private_off_pump_workflow() -> None: "read-only", ) - missing = _missing_terms(tui, required) + missing = _missing_terms(section, required) assert not missing, f"docs/tui/index.md is missing {missing!r}" + assert re.search( + r"press `e`.*review.*remembered.*directory and filename template.*exact filename", + normalized, + ) + assert re.search(r"slash commands.*one-shot", normalized) + assert re.search(r"without `path`, both commands.*private export directory", normalized) + assert re.search(r"`/export-thread \[path\]`.*observed thread", normalized) def test_export_guide_defines_reviewed_tui_destination() -> None: @@ -121,8 +130,8 @@ def test_export_guide_defines_reviewed_tui_destination() -> None: normalized, ) assert re.search( - r"first use.*after the preferences are saved successfully.*" - r"remembered directory and template", + r"first use.*after the preferences are saved successfully" + r".*remembered directory and template", normalized, ) assert re.search(r"local time.*filesystem-safe", normalized) diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 920ea0579..f6a0a9820 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -511,7 +511,7 @@ async def test_dialog_fits_compact_terminal_without_horizontal_scroll( assert review.show_vertical_scrollbar is False -@pytest.mark.parametrize("size", ((40, 12), (30, 10))) +@pytest.mark.parametrize("size", [(40, 12), (30, 10)]) async def test_invalid_template_error_is_visible_in_small_terminal( size: tuple[int, int], tmp_path: pathlib.Path, @@ -532,7 +532,7 @@ async def test_invalid_template_error_is_visible_in_small_terminal( assert template.has_focus -@pytest.mark.parametrize("size", ((40, 12), (30, 10))) +@pytest.mark.parametrize("size", [(40, 12), (30, 10)]) async def test_review_and_edit_are_reachable_in_small_terminal( size: tuple[int, int], tmp_path: pathlib.Path, From b9d90363eebca74eb30b188dd2859dfcb7a842c4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:51:16 -0500 Subject: [PATCH 52/71] agentgrep(fix[tui]): Keep save errors inline why: A failed reviewed save restored its retained inline draft and also emitted a duplicate toast carrying the same error. what: - Let a mounted saving dialog consume its worker failure inline. - Preserve path-free error toasts for slash-command and no-dialog failures. - Cover retained drafts, cancel keys, and the no-dialog writer route. --- src/agentgrep/ui/layouts/hud.py | 6 ++++-- tests/test_ui_export.py | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index 8f3ebaf92..aa44d4ac5 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -1476,8 +1476,10 @@ def _apply_export_completed(self, generation: int, event: object) -> None: if not self.is_mounted: return if event.error is not None: - if self._export_dialog is not None: - self._export_dialog.export_failed(event.error) + dialog = self._export_dialog + if dialog is not None and dialog.is_mounted and dialog.phase == "saving": + dialog.export_failed(event.error) + return self.notify( event.error, title="Export failed", diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 2520c2613..72a8d0448 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -407,7 +407,8 @@ def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: assert hud._export_dialog is dialog assert not (selected_dir / filename).exists() assert load_export_preferences(tmp_path / "home").preferences == original - assert notes[0][1]["severity"] == "error" + assert _static_text(dialog, "#export-error") == "export could not be written" + assert notes == [] @pytest.mark.parametrize("key", ["escape", "ctrl+c"]) @@ -455,7 +456,7 @@ def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: retained_during_save = hud._export_dialog is dialog phase_during_save = dialog.phase release.set() - await _wait_for(lambda: bool(notes)) + await _wait_for(lambda: dialog.phase == "edit") assert active_during_save assert retained_during_save @@ -465,6 +466,8 @@ def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: assert dialog.phase == "edit" assert dialog.query_one("#export-directory", ExportDirectoryPicker).value == directory assert dialog.query_one("#export-template", Input).value == template + assert _static_text(dialog, "#export-error") == "export could not be written" + assert notes == [] @pytest.mark.slow From 22dd01e0056446de26a886cddd3b09dd6badf550 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:52:54 -0500 Subject: [PATCH 53/71] agentgrep(fix[tui]): Compact home drafts why: Absolute export directories inside the active session home exposed a machine-specific prefix in the edit and review stages and in remembered preferences. what: - Compact session-home directories to tilde drafts without resolving paths. - Apply compaction to loaded and newly entered directory values. - Cover defaults, legacy preferences, outside paths, and persistence. --- src/agentgrep/ui/_export_preferences.py | 31 +++++++++++ src/agentgrep/ui/widgets/export_dialog.py | 11 +++- tests/test_ui_export.py | 46 ++++++++++++++++ tests/test_ui_export_dialog.py | 66 ++++++++++++++++++++++- tests/test_ui_export_preferences.py | 31 +++++++++++ 5 files changed, 181 insertions(+), 4 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index bfe3de14b..2658a928b 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -41,6 +41,7 @@ "ExportPreferences", "ExportPreferencesError", "ExportPreferencesLoad", + "compact_export_directory", "default_export_directory", "export_preferences_path", "load_export_preferences", @@ -70,6 +71,36 @@ class ExportPreferencesError(Exception): """A path-free preference or filename failure.""" +def compact_export_directory(value: str, home: pathlib.Path) -> str: + """Compact an absolute directory lexically contained by ``home``. + + Parameters + ---------- + value : str + Literal directory draft. + home : pathlib.Path + Explicit TUI session home. + + Returns + ------- + str + ``~`` or a current-user tilde path for values under ``home``; + otherwise the original literal value. + """ + candidate = pathlib.Path(value) + normalized_home = pathlib.Path(os.path.normpath(os.fspath(home))) + if not candidate.is_absolute() or not normalized_home.is_absolute(): + return value + normalized_candidate = pathlib.Path(os.path.normpath(value)) + try: + relative = normalized_candidate.relative_to(normalized_home) + except ValueError: + return value + if relative == pathlib.Path(): + return "~" + return f"~{os.sep}{relative}" + + def _xdg_path(variable: str, fallback: pathlib.Path) -> pathlib.Path: """Return a configured non-empty XDG root or ``fallback``.""" configured = os.environ.get(variable) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index ae12df009..9f9802e16 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -22,6 +22,7 @@ from agentgrep.ui._export_preferences import ( ExportPreferences, ExportPreferencesError, + compact_export_directory, default_export_directory, render_export_filename, resolve_export_directory, @@ -188,7 +189,10 @@ def __init__( self._home = home self._on_confirm = on_confirm self._timestamp = timestamp or datetime.datetime.now().astimezone() - self._initial_preferences = preferences + self._initial_preferences = dataclasses.replace( + preferences, + directory=compact_export_directory(preferences.directory, home), + ) self._phase: ExportPhase = "edit" self._validation_generation = 0 self._intent: ExportIntent | None = None @@ -349,8 +353,11 @@ def _start_validation(self) -> None: template = self.query_one("#export-template", Input) picker = self.query_one("#export-directory", ExportDirectoryPicker) self._edit_focus = "template" if template.has_focus else "directory" + directory = compact_export_directory(picker.value, self._home) + if directory != picker.value: + picker.value = directory draft = ExportDraft( - directory=picker.value, + directory=directory, filename_template=template.value, timestamp=self._timestamp, ) diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 72a8d0448..c196efdcf 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -357,6 +357,52 @@ def tracked_save(home: pathlib.Path, preferences: ExportPreferences) -> None: assert filename in str(notes[0][0][0]) +@pytest.mark.slow +async def test_absolute_home_preference_persists_as_tilde( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A legacy absolute home draft is reviewed and re-saved without its prefix.""" + home = tmp_path / "home" + export_dir = home / "Exports" + export_dir.mkdir(parents=True) + config_home = tmp_path / "config" + config_home.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + save_export_preferences( + home, + ExportPreferences( + directory=str(export_dir), + filename_template="{title}.md", + ), + ) + record = _record(tmp_path, "body", ordinal=1, title="Private Draft") + app = _build_empty_ui_app(tmp_path, monkeypatch) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + + await pilot.press("e") + await pilot.pause() + dialog = t.cast("ExportDialog", app.screen) + picker = dialog.query_one("#export-directory", ExportDirectoryPicker) + assert picker.value == "~/Exports" + await pilot.press("enter", "enter") + await _wait_for(lambda: dialog.phase == "review") + assert _static_text(dialog, "#export-review-directory") == "~/Exports" + + await pilot.press("y") + await _wait_for(lambda: app.screen is hud) + + assert (export_dir / "private-draft.md").is_file() + assert load_export_preferences(home).preferences == ExportPreferences( + directory="~/Exports", + filename_template="{title}.md", + ) + + @pytest.mark.slow async def test_export_failure_restores_draft_without_saving_preferences( tmp_path: pathlib.Path, diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index f6a0a9820..7999c99e4 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -268,6 +268,68 @@ async def test_first_use_default_directory_is_created_privately( assert stat.S_IMODE(directory.stat().st_mode) == 0o700 +async def test_home_default_is_reviewed_as_tilde( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The clean fallback default never exposes the absolute session home.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.delenv("XDG_DATA_HOME", raising=False) + directory = default_export_directory(home) + app = _ExportDialogHost( + home, + lambda _intent: True, + directory=str(directory), + ) + async with app.run_test(size=(60, 16)) as pilot: + picker = app.screen.query_one("#export-directory", ExportDirectoryPicker) + assert picker.value == "~/.local/share/agentgrep/exports" + + await _open_review(app, pilot) + + assert _text(app, "#export-review-directory") == "~/.local/share/agentgrep/exports" + assert str(home) not in _text(app, "#export-review-directory") + + +async def test_directory_outside_home_remains_literal(tmp_path: pathlib.Path) -> None: + """A selected directory outside the session home keeps its exact draft text.""" + home = tmp_path / "home" + directory = tmp_path / "outside" + home.mkdir() + directory.mkdir() + app = _ExportDialogHost( + home, + lambda _intent: True, + directory=str(directory), + ) + async with app.run_test(size=(60, 16)) as pilot: + picker = app.screen.query_one("#export-directory", ExportDirectoryPicker) + assert picker.value == str(directory) + + await _open_review(app, pilot) + + assert _text(app, "#export-review-directory") == str(directory) + + +async def test_submitted_absolute_home_directory_is_compacted( + tmp_path: pathlib.Path, +) -> None: + """A newly entered absolute home draft compacts before review.""" + home = tmp_path / "home" + directory = home / "Exports" + directory.mkdir(parents=True) + app = _ExportDialogHost(home, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + picker = app.screen.query_one("#export-directory", ExportDirectoryPicker) + picker.value = str(directory) + await pilot.press("enter", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase == "review") + + assert picker.value == "~/Exports" + assert _text(app, "#export-review-directory") == "~/Exports" + + async def test_missing_arbitrary_directory_is_not_created(tmp_path: pathlib.Path) -> None: """Validation never creates a missing user-entered directory tree.""" directory = tmp_path / "missing" / "arbitrary" @@ -342,7 +404,7 @@ async def test_review_shows_directory_and_filename_literally(tmp_path: pathlib.P async with app.run_test(size=(60, 16)) as pilot: await _open_review(app, pilot) - assert _text(app, "#export-review-directory") == str(directory) + assert _text(app, "#export-review-directory") == "~/exports-[literal]" assert _text(app, "#export-review-filename") == ("2026-07-14 09-08-07 - title-literal.md") confirm = app.screen.query_one("#export-confirm", OptionList) assert confirm._markup is False @@ -412,7 +474,7 @@ async def test_y_invokes_once_and_enters_saving(tmp_path: pathlib.Path) -> None: assert seen[0] == ExportIntent( destination=(tmp_path / "2026-07-14 09-08-07 - machine-readable-title.md"), preferences=ExportPreferences( - directory=str(tmp_path), + directory="~", filename_template="{date} {time} - {title}.md", ), ) diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index 4261f9307..a8b66e65a 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -124,6 +124,37 @@ def test_resolve_export_directory_rejects_other_users( resolve_export_directory("~other/Exports", tmp_path / "home") +def test_compact_export_directory_uses_only_explicit_home( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Absolute home drafts compact lexically without global home or resolution.""" + home = tmp_path / "session-home" + process_home = tmp_path / "process-home" + outside = tmp_path / "outside" / "Exports" + monkeypatch.setenv("HOME", str(process_home)) + unexpected_resolve = "directory compaction must not resolve symlinks" + + def reject_resolve(_path: pathlib.Path, *_args: object, **_kwargs: object) -> t.NoReturn: + raise AssertionError(unexpected_resolve) + + monkeypatch.setattr(pathlib.Path, "resolve", reject_resolve) + + assert export_preferences.compact_export_directory(str(home), home) == "~" + assert ( + export_preferences.compact_export_directory( + str(home / "draft" / ".." / "Exports"), + home, + ) + == "~/Exports" + ) + assert export_preferences.compact_export_directory(str(outside), home) == str(outside) + assert export_preferences.compact_export_directory("~/Exports", home) == "~/Exports" + assert export_preferences.compact_export_directory("relative/Exports", home) == ( + "relative/Exports" + ) + + def test_default_export_filename_is_frozen_local_ascii() -> None: """The default template compiles to the reviewed local-time basename.""" when = datetime.datetime(2026, 7, 14, 9, 8, 7).astimezone() From 599c1782477cd3b2c17acc24e2d347c0956fd3dc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:54:18 -0500 Subject: [PATCH 54/71] agentgrep(fix[tui]): Clear edit on Ctrl-C why: Ctrl-C dismissed the export modal immediately while editing, making a familiar terminal clear-field gesture unnecessarily destructive. what: - Clear a focused non-empty directory or template before dismissal. - Preserve focus after clearing and dismiss on a second Ctrl-C. - Retain immediate review cancellation and saving protection. --- src/agentgrep/ui/widgets/export_dialog.py | 18 ++++++++-- tests/test_ui_export_dialog.py | 40 +++++++++++++++++++---- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index 9f9802e16..e876bf12c 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -290,9 +290,21 @@ def action_escape(self) -> None: @_runtime.pump_only def action_cancel(self) -> None: - """Dismiss unless a durable save is already active.""" - if self._phase != "saving": - self.dismiss(None) + """Clear the focused edit once, or dismiss before durable saving.""" + if self._phase == "saving": + return + if self._phase == "edit": + directory = self.query_one( + "#export-directory", + ExportDirectoryPicker, + ).query_one(Input) + template = self.query_one("#export-template", Input) + editor = directory if directory.has_focus else template if template.has_focus else None + if editor is not None and editor.value: + editor.value = "" + editor.focus() + return + self.dismiss(None) @_runtime.pump_only def action_review_no(self) -> None: diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 7999c99e4..962c5e72a 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -497,16 +497,44 @@ async def test_saving_ignores_cancel_keys(tmp_path: pathlib.Path, key: str) -> N assert dialog.phase == "saving" -@pytest.mark.parametrize("phase", ["edit", "review"]) -async def test_ctrl_c_dismisses_before_saving( +@pytest.mark.slow +async def test_ctrl_c_dismisses_from_review(tmp_path: pathlib.Path) -> None: + """Ctrl-C cancels the reviewed draft while no durable worker is active.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + + await pilot.press("ctrl+c") + await _wait_for(pilot, lambda: app.dismissed is None) + + assert not app.query(ExportDialog) + + +@pytest.mark.parametrize("focused", ["directory", "template"]) +@pytest.mark.slow +async def test_ctrl_c_clears_focused_edit_before_dismissal( tmp_path: pathlib.Path, - phase: str, + focused: str, ) -> None: - """Ctrl-C still cancels while the dialog has no durable worker.""" + """Ctrl-C clears a focused edit once, then cancels its empty draft.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) async with app.run_test(size=(60, 16)) as pilot: - if phase == "review": - await _open_review(app, pilot) + dialog = _dialog(app) + directory = dialog.query_one("#export-directory", ExportDirectoryPicker).query_one(Input) + template = dialog.query_one("#export-template", Input) + field, other = (directory, template) if focused == "directory" else (template, directory) + other_value = other.value + field.focus() + await pilot.pause() + + await pilot.press("ctrl+c") + await pilot.pause() + + assert app.screen is dialog + assert dialog.phase == "edit" + assert field.value == "" + assert field.has_focus + assert other.value == other_value await pilot.press("ctrl+c") await _wait_for(pilot, lambda: app.dismissed is None) From 43682e94a6f0db03f5c1ec8655158ff6b8efce17 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:55:43 -0500 Subject: [PATCH 55/71] agentgrep(fix[tui]): Keep async errors visible why: Restoring an asynchronous export failure at compact terminal sizes let deferred focus scrolling move the sole inline feedback outside the viewport. what: - Restore retained editor focus before applying asynchronous feedback. - Reveal the error through a named callback after refreshed layout. - Cover retained focus and visibility at 30 by 10. --- src/agentgrep/ui/widgets/export_dialog.py | 13 ++++++++++-- tests/test_ui_export_dialog.py | 24 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index e876bf12c..2e8e13d76 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -453,12 +453,21 @@ def _show_edit(self, error: str | None = None) -> None: Content("Tab to move · Enter to review · Ctrl-C to cancel"), ) self._refresh_preview() - if error is not None: - self._update_error(error) if self._edit_focus == "directory": picker.focus_input() else: template.focus() + if error is not None: + self._update_error(error) + self.call_after_refresh(self._reveal_error) + + @_runtime.pump_only + def _reveal_error(self) -> None: + """Reveal retained feedback after focus and stage layout settle.""" + self.query_one("#export-error", Static).scroll_visible( + animate=False, + immediate=True, + ) @_runtime.pump_only def _show_review(self, intent: ExportIntent) -> None: diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 962c5e72a..661f80fe1 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -570,6 +570,30 @@ async def test_export_failed_restores_edit_with_values(tmp_path: pathlib.Path) - assert app.screen.query_one("#export-template", Input).has_focus +async def test_export_failed_keeps_error_visible_in_small_terminal( + tmp_path: pathlib.Path, +) -> None: + """A retained asynchronous failure stays visible with template focus.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(30, 10)) as pilot: + await _open_review(app, pilot) + await pilot.press("y") + dialog = _dialog(app) + dialog.export_failed("Export failed inline") + template = dialog.query_one("#export-template", Input) + error = dialog.query_one("#export-error", Static) + await _wait_for( + pilot, + lambda: template.has_focus and error.region.bottom <= 10, + ) + + assert dialog.phase == "edit" + assert template.has_focus + assert _text(app, "#export-error") == "Export failed inline" + assert error.region.y >= 0 + assert error.region.bottom <= 10 + + async def test_export_succeeded_dismisses(tmp_path: pathlib.Path) -> None: """An asynchronous write success closes the retained saving modal.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) From 3de37634b831c9da27fbab8a49e6059c77080411 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:56:28 -0500 Subject: [PATCH 56/71] agentgrep(fix[tui]): Gate deferred errors why: A post-refresh error reveal could outlive its edit state and scroll a dialog after rapid dismissal or after its feedback had been cleared. what: - Scope deferred reveals to a generated non-empty error request. - Require the mounted active dialog to remain in its edit phase. - Invalidate pending reveals on updates, review, dismissal, and unmount. - Cover rapid Escape and cleared-error races deterministically. --- src/agentgrep/ui/widgets/export_dialog.py | 41 ++++++++++++--- tests/test_ui_export_dialog.py | 64 +++++++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index 2e8e13d76..8bc452702 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -195,6 +195,8 @@ def __init__( ) self._phase: ExportPhase = "edit" self._validation_generation = 0 + self._error_reveal_generation = 0 + self._pending_error_reveal: tuple[int, str] | None = None self._intent: ExportIntent | None = None self._edit_focus = "template" @@ -245,6 +247,7 @@ def on_mount(self) -> None: @_runtime.pump_only def on_unmount(self) -> None: """Invalidate and cancel validator work before teardown.""" + self._invalidate_error_reveal() self._validation_generation += 1 self.workers.cancel_group(self, _VALIDATION_WORKER_GROUP) @@ -286,7 +289,7 @@ def action_escape(self) -> None: if self._phase == "review": self._show_edit() return - self.dismiss(None) + self._dismiss_dialog() @_runtime.pump_only def action_cancel(self) -> None: @@ -304,7 +307,7 @@ def action_cancel(self) -> None: editor.value = "" editor.focus() return - self.dismiss(None) + self._dismiss_dialog() @_runtime.pump_only def action_review_no(self) -> None: @@ -328,7 +331,13 @@ def export_failed(self, message: str) -> None: def export_succeeded(self) -> None: """Dismiss after the asynchronous writer reports success.""" if self.is_mounted and self._phase == "saving": - self.dismiss(None) + self._dismiss_dialog() + + @_runtime.pump_only + def _dismiss_dialog(self) -> None: + """Invalidate deferred feedback before dismissing the modal.""" + self._invalidate_error_reveal() + self.dismiss(None) @_runtime.pump_only def _refresh_preview(self) -> bool: @@ -352,11 +361,18 @@ def _refresh_preview(self) -> bool: @_runtime.pump_only def _update_error(self, message: str) -> None: """Update inline feedback and expose it in a compact scrolling edit stage.""" + self._invalidate_error_reveal() error = self.query_one("#export-error", Static) error.update(Content(message)) if message: error.scroll_visible(animate=False, immediate=True) + @_runtime.pump_only + def _invalidate_error_reveal(self) -> None: + """Make every previously scheduled error reveal stale.""" + self._error_reveal_generation += 1 + self._pending_error_reveal = None + @_runtime.pump_only def _start_validation(self) -> None: """Snapshot the draft and launch one exclusive validator worker.""" @@ -459,11 +475,23 @@ def _show_edit(self, error: str | None = None) -> None: template.focus() if error is not None: self._update_error(error) - self.call_after_refresh(self._reveal_error) + request = (self._error_reveal_generation, error) + self._pending_error_reveal = request + self.call_after_refresh(self._reveal_error, *request) @_runtime.pump_only - def _reveal_error(self) -> None: - """Reveal retained feedback after focus and stage layout settle.""" + def _reveal_error(self, generation: int, message: str) -> None: + """Reveal only the current feedback on the active edit screen.""" + request = (generation, message) + if ( + not message + or self._pending_error_reveal != request + or not self.is_mounted + or self.app.screen is not self + or self._phase != "edit" + ): + return + self._pending_error_reveal = None self.query_one("#export-error", Static).scroll_visible( animate=False, immediate=True, @@ -472,6 +500,7 @@ def _reveal_error(self) -> None: @_runtime.pump_only def _show_review(self, intent: ExportIntent) -> None: """Show the literal directory and exact basename with No selected.""" + self._invalidate_error_reveal() self._phase = "review" self.query_one("#export-edit", VerticalScroll).display = False self.query_one("#export-review", VerticalScroll).display = True diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 661f80fe1..9853b638a 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -100,6 +100,29 @@ def _text(app: _ExportDialogHost, selector: str) -> str: return getattr(content, "plain", str(content)) +def _observe_error_scrolls( + monkeypatch: pytest.MonkeyPatch, + app: _ExportDialogHost, + dialog: ExportDialog, +) -> list[bool]: + """Record whether each error scroll ran on the active dialog.""" + observations: list[bool] = [] + original_scroll_visible = Static.scroll_visible + + def observed_scroll_visible( + widget: Static, + *args: t.Any, + **kwargs: t.Any, + ) -> None: + """Record matching calls before forwarding to Textual.""" + if widget.id == "export-error": + observations.append(app.screen is dialog) + original_scroll_visible(widget, *args, **kwargs) + + monkeypatch.setattr(Static, "scroll_visible", observed_scroll_visible) + return observations + + async def _open_review(app: _ExportDialogHost, pilot: Pilot[None]) -> None: """Submit the default draft and wait for its review stage.""" await pilot.press("tab", "enter") @@ -594,6 +617,47 @@ async def test_export_failed_keeps_error_visible_in_small_terminal( assert error.region.bottom <= 10 +async def test_pending_error_reveal_ignores_rapid_dismiss( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A post-refresh reveal never touches a dialog dismissed by Escape.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(30, 10)) as pilot: + await _open_review(app, pilot) + await pilot.press("y") + dialog = _dialog(app) + observations = _observe_error_scrolls(monkeypatch, app, dialog) + + dialog.export_failed("Export failed inline") + dialog.action_escape() + await _wait_for(pilot, lambda: app.dismissed is None) + await pilot.pause() + + assert observations == [True] + + +async def test_pending_error_reveal_ignores_cleared_error( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cleared edit error invalidates its pending post-refresh reveal.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(30, 10)) as pilot: + await _open_review(app, pilot) + await pilot.press("y") + dialog = _dialog(app) + observations = _observe_error_scrolls(monkeypatch, app, dialog) + + dialog.export_failed("Export failed inline") + dialog._update_error("") + await pilot.pause() + + assert dialog.phase == "edit" + assert _text(app, "#export-error") == "" + assert observations == [True] + + async def test_export_succeeded_dismisses(tmp_path: pathlib.Path) -> None: """An asynchronous write success closes the retained saving modal.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) From bf5233aecf6abb966efe646f862152238dc196a3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:57:17 -0500 Subject: [PATCH 57/71] agentgrep(fix[tui]): Reject unsafe paths why: Directory drafts could contain control, bidi-format, or surrogate code points that made reviewed destinations and completion rows ambiguous. what: - Share one reviewability rule across preference load, save, and submit. - Omit unsafe existing directory names from bounded completion. - Cover control, invisible, bidi, surrogate, config, and dialog cases. --- src/agentgrep/ui/_export_preferences.py | 19 +++++- src/agentgrep/ui/widgets/directory_popup.py | 5 ++ tests/test_ui_export_dialog.py | 14 +++++ tests/test_ui_export_directory_popup.py | 23 +++++++ tests/test_ui_export_preferences.py | 68 +++++++++++++++++++++ 5 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index 2658a928b..f36e00f6c 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -29,7 +29,7 @@ _FILENAME_ERROR = "Export filename is invalid" _SCHEMA_KEYS = frozenset({"version", "directory", "filename_template"}) _TEMPLATE_TOKENS = frozenset({"date", "time", "title"}) -_UNSAFE_FILENAME_CATEGORIES = frozenset({"Cc", "Cf", "Cs"}) +_UNREVIEWABLE_UNICODE_CATEGORIES = frozenset({"Cc", "Cf", "Cs"}) _CONFIG_DIRECTORY_NAME = "agentgrep" _PREFERENCES_FILENAME = "tui-export.json" @@ -71,6 +71,14 @@ class ExportPreferencesError(Exception): """A path-free preference or filename failure.""" +def _validate_directory_value(value: str) -> None: + """Reject directory text that cannot be reviewed reliably.""" + if not isinstance(value, str) or any( + unicodedata.category(character) in _UNREVIEWABLE_UNICODE_CATEGORIES for character in value + ): + raise ExportPreferencesError(_DIRECTORY_ERROR) + + def compact_export_directory(value: str, home: pathlib.Path) -> str: """Compact an absolute directory lexically contained by ``home``. @@ -161,6 +169,7 @@ def resolve_export_directory(value: str, home: pathlib.Path) -> pathlib.Path: ExportPreferencesError If an other-user tilde spelling is supplied. """ + _validate_directory_value(value) if value == "~": return home current_home_prefix = f"~{os.sep}" @@ -193,7 +202,8 @@ def _validate_filename(filename: str) -> None: if "{" in filename or "}" in filename: raise ExportPreferencesError(_FILENAME_ERROR) if any( - unicodedata.category(character) in _UNSAFE_FILENAME_CATEGORIES for character in filename + unicodedata.category(character) in _UNREVIEWABLE_UNICODE_CATEGORIES + for character in filename ): raise ExportPreferencesError(_FILENAME_ERROR) if "/" in filename or "\\" in filename: @@ -217,7 +227,8 @@ def _validate_filename_template(template: str) -> None: if not isinstance(template, str) or len(template) > MAX_TEMPLATE_CHARS: raise ExportPreferencesError(_FILENAME_ERROR) if any( - unicodedata.category(character) in _UNSAFE_FILENAME_CATEGORIES for character in template + unicodedata.category(character) in _UNREVIEWABLE_UNICODE_CATEGORIES + for character in template ): raise ExportPreferencesError(_FILENAME_ERROR) if "/" in template or "\\" in template: @@ -317,6 +328,7 @@ def _parse_preferences(payload: bytes) -> ExportPreferences: raise ValueError if not isinstance(directory, str) or not isinstance(filename_template, str): raise TypeError + _validate_directory_value(directory) _validate_filename_template(filename_template) return ExportPreferences(directory=directory, filename_template=filename_template) @@ -455,6 +467,7 @@ def _serialize_preferences(preferences: ExportPreferences) -> bytes: str, ): raise ExportPreferencesError(_PREFERENCES_SAVE_ERROR) + _validate_directory_value(preferences.directory) _validate_filename_template(preferences.filename_template) payload = json.dumps( { diff --git a/src/agentgrep/ui/widgets/directory_popup.py b/src/agentgrep/ui/widgets/directory_popup.py index 09cac2b3f..c89566621 100644 --- a/src/agentgrep/ui/widgets/directory_popup.py +++ b/src/agentgrep/ui/widgets/directory_popup.py @@ -21,6 +21,7 @@ from agentgrep.ui import _runtime from agentgrep.ui._export_preferences import ( ExportPreferencesError, + _validate_directory_value, resolve_export_directory, ) @@ -124,6 +125,10 @@ def _enumerate_directory_candidates( break if not entry.name.startswith(prefix): continue + try: + _validate_directory_value(entry.name) + except ExportPreferencesError: + continue try: is_directory = entry.is_dir(follow_symlinks=False) except OSError: diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 9853b638a..ef5033e8f 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -370,6 +370,20 @@ async def test_missing_arbitrary_directory_is_not_created(tmp_path: pathlib.Path assert not directory.exists() +async def test_existing_bidi_directory_is_rejected(tmp_path: pathlib.Path) -> None: + """An existing path with unreviewable format controls cannot reach review.""" + home = tmp_path / "home" + directory = home / "Ex\u202eports" + directory.mkdir(parents=True) + app = _ExportDialogHost(home, lambda _intent: True, directory=str(directory)) + async with app.run_test(size=(60, 16)) as pilot: + await pilot.press("tab", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase != "validating") + + assert _dialog(app).phase == "edit" + assert _text(app, "#export-error") == "Export directory is invalid" + + async def test_default_directory_creation_rejects_symlinked_app_path( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index fb4151bab..931b7deff 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -356,6 +356,29 @@ def test_candidate_labels_are_basenames_and_values_preserve_prefix( assert result.values == (DirectoryCandidate(value=expected, label="alpha"),) +@pytest.mark.parametrize("unsafe", ("\u200b", "\u202e")) +def test_completion_omits_unreviewable_directory_names( + unsafe: str, + tmp_path: pathlib.Path, +) -> None: + """Existing invisible and bidi directory names never enter completion.""" + choices = tmp_path / "choices" + choices.mkdir() + (choices / "alpha").mkdir() + (choices / f"a{unsafe}hidden").mkdir() + + result = directory_popup._enumerate_directory_candidates( + f"{choices}{os.sep}a", + home=tmp_path, + candidate_limit=DIRECTORY_CANDIDATE_LIMIT, + scan_limit=DIRECTORY_SCAN_LIMIT, + ) + + assert result.values == ( + DirectoryCandidate(value=f"{choices}{os.sep}alpha{os.sep}", label="alpha"), + ) + + async def test_popup_is_literal_bounded_off_pump_and_reports_truncation( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index a8b66e65a..bb734fdaf 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -124,6 +124,19 @@ def test_resolve_export_directory_rejects_other_users( resolve_export_directory("~other/Exports", tmp_path / "home") +@pytest.mark.parametrize("unsafe", ("\n", "\u202e", "\ud800")) +def test_resolve_export_directory_rejects_unreviewable_unicode( + unsafe: str, + tmp_path: pathlib.Path, +) -> None: + """Control, format, and surrogate code points cannot enter a path draft.""" + with pytest.raises( + ExportPreferencesError, + match=r"^Export directory is invalid$", + ): + resolve_export_directory(f"~/Ex{unsafe}ports", tmp_path / "home") + + def test_compact_export_directory_uses_only_explicit_home( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -345,6 +358,61 @@ def test_invalid_export_preferences_return_defaults_with_warning( assert loaded.warning == "Export preferences could not be read" +@pytest.mark.parametrize("unsafe", ("\n", "\u202e", "\ud800")) +def test_unreviewable_directory_preferences_are_not_loaded( + unsafe: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Stored control, format, and surrogate paths degrade to safe defaults.""" + config_home = tmp_path / "config" + data_home = tmp_path / "data" + config_path = config_home / "agentgrep" / "tui-export.json" + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps( + { + "version": 1, + "directory": f"~/Ex{unsafe}ports", + "filename_template": "{title}.md", + }, + ), + encoding="utf-8", + ) + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + monkeypatch.setenv("XDG_DATA_HOME", str(data_home)) + + loaded = load_export_preferences(tmp_path / "home") + + assert loaded == ExportPreferencesLoad( + ExportPreferences(directory=str(data_home / "agentgrep" / "exports")), + "Export preferences could not be read", + ) + + +@pytest.mark.parametrize("unsafe", ("\n", "\u202e", "\ud800")) +def test_unreviewable_directory_preferences_are_not_saved( + unsafe: str, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Persistence rejects directory values that cannot be reviewed reliably.""" + config_home = tmp_path / "config" + config_home.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + + with pytest.raises( + ExportPreferencesError, + match=r"^Export preferences could not be saved$", + ): + save_export_preferences( + tmp_path / "home", + ExportPreferences(directory=f"~/Ex{unsafe}ports"), + ) + + assert not export_preferences_path(tmp_path / "home").exists() + + def test_export_preferences_fifo_returns_promptly_with_path_free_warning( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From ab2af129a5fa67206fe2eeeae3d295dc4c44ab8c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 17:59:07 -0500 Subject: [PATCH 58/71] agentgrep(fix[tui]): Defer default creation why: Draft validation created the first-use export directory before the user accepted Save, so review, No, and cancel crossed an unexpected mutation boundary. what: - Validate a missing exact app default through a read-only no-symlink prefix walk. - Create that directory securely only in the accepted export worker. - Keep arbitrary missing and symlinked paths rejected. - Document artifact and private-preference mutation boundaries. --- docs/cli/export.md | 22 +++++++----- docs/dev/adr/0017-portable-record-export.md | 15 ++++---- docs/tui/index.md | 17 +++++---- src/agentgrep/ui/layouts/hud.py | 6 ++++ src/agentgrep/ui/widgets/export_dialog.py | 39 ++++++++++++++------- tests/test_export_docs.py | 19 ++++++++++ tests/test_ui_export.py | 1 + tests/test_ui_export_dialog.py | 15 +++++--- 8 files changed, 96 insertions(+), 38 deletions(-) diff --git a/docs/cli/export.md b/docs/cli/export.md index 0cd6a4ea2..789256960 100644 --- a/docs/cli/export.md +++ b/docs/cli/export.md @@ -30,13 +30,17 @@ path. Submitting the draft shows the directory and exact filename separately. The confirmation starts on **No**; No returns to editing with both values intact. -Save writes only the reviewed explicit no-clobber destination. If that name -already exists, agentgrep returns to the same draft instead of replacing the -file or silently choosing another name. Automatic private exports requested by -the HUD slash commands keep their canonical-ID names. CLI and MCP do not -consume the TUI preference: the CLI still uses standard output or an explicit -`--output` path, and MCP still returns a bounded inline artifact, accepts no -local destination, and gains no filesystem write authority. +Save is the mutation boundary: No and cancel perform no filesystem mutation. +An accepted Save creates the exact app-owned default directory privately when +needed, writes only the reviewed explicit no-clobber artifact, then attempts to +write the TUI-private preference file. If the artifact name already exists, +agentgrep returns to the same draft instead of replacing the file or silently +choosing another name; a later preference failure does not erase a completed +artifact. Automatic private exports requested by the HUD slash commands keep +their canonical-ID names. CLI and MCP do not consume the TUI preference: the +CLI still uses standard output or an explicit `--output` path, and MCP still +returns a bounded inline artifact, accepts no local destination, and gains no +filesystem write authority. ## Examples @@ -113,7 +117,9 @@ export cannot replace history by choosing it as the destination. The completed artifact is installed atomically with private file permissions. Errors do not include the destination or source path. These rules preserve agentgrep's read-only treatment of Codex, Claude Code, Cursor, and every other -source store; only the chosen export artifact is written. +source store. CLI export writes only the chosen artifact; a successful reviewed +TUI Save may additionally update its TUI-private preference file as described +above. ## Exit status diff --git a/docs/dev/adr/0017-portable-record-export.md b/docs/dev/adr/0017-portable-record-export.md index 18cee599e..eaced26bc 100644 --- a/docs/dev/adr/0017-portable-record-export.md +++ b/docs/dev/adr/0017-portable-record-export.md @@ -103,12 +103,15 @@ observed-thread export instead of writing a mixed view. Pressing `e` in a content pane captures the exact selected record and opens one staged TUI dialog. The dialog remembers its reviewed directory and filename -template in a small TUI-private file under the platform user configuration -directory. It previews a filename, validates an existing directory, then shows -the directory and exact basename separately with **No** selected. No returns to -the retained draft; Save writes the explicit no-clobber destination. CLI and -MCP do not consume this preference or gain any additional filesystem -authority. +template in a TUI-private preference file under the platform user +configuration directory. It previews a filename, validates an existing +directory or the uncreated exact app default, then shows the directory and +exact basename separately with **No** selected. No returns to the retained +draft. Save is the mutation boundary: No and cancel perform no filesystem +mutation. An accepted Save securely creates the app default when needed, +writes the explicit no-clobber destination, then attempts to persist the +reviewed preference. CLI and MCP do not consume this preference or gain any +additional filesystem authority. The MCP {tooliconl}`export_records` tool accepts one to 20 unique `agref1:` search refs and no query, cursor, or local destination. It resolves refs with diff --git a/docs/tui/index.md b/docs/tui/index.md index 0d054d38e..b4e5125a9 100644 --- a/docs/tui/index.md +++ b/docs/tui/index.md @@ -302,10 +302,12 @@ The HUD offers two pi-like, one-shot slash commands: Press `e` with the results list or detail pane focused to review the exact selected record before saving it. The dialog starts from the remembered explicit directory and filename template, previews the exact filename, and -keeps both values when No returns to editing. Save writes that reviewed new -destination and remembers the values only after its preferences persist. The -contextual `/keys` panel lists the shortcut without adding it to the compact -footer. +keeps both values when No returns to editing. Save is the mutation boundary: +No and cancel perform no filesystem mutation. Save securely creates the exact +app default when needed, writes that reviewed new destination, then attempts to +write the TUI-private preference file. The remembered values change only when +that preference write succeeds. The contextual `/keys` panel lists the +shortcut without adding it to the compact footer. The slash commands do not read or change those remembered values. Supplying `PATH` gives that invocation an explicit one-shot destination. @@ -327,9 +329,10 @@ is already in progress, and an observed-thread export cancels if its result view changes while the HUD is taking the snapshot. Export does not replace the loaded results or change the detail selection. -Only the new artifact is written; source stores remain read-only. See -{ref}`ADR 0017 ` for the payload, fidelity, and -file-safety contract. +Source stores remain read-only. A successful reviewed Save may write both the +new artifact and its TUI-private preference file; one-shot slash commands write +only their artifact. See {ref}`ADR 0017 ` for the +payload, fidelity, and file-safety contract. ## Completion diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index aa44d4ac5..4e4777136 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -1366,6 +1366,7 @@ def _run_export_in_thread( """Resolve, render, and durably write one pump-owned export snapshot.""" from agentgrep.record_export import ( ExportError, + _ensure_private_directory, render_export, write_export, write_private_export, @@ -1387,6 +1388,11 @@ def _run_export_in_thread( written = write_private_export(artifact) else: destination = pathlib.Path(snapshot.destination).expanduser() + if ( + snapshot.preferences is not None + and destination.parent == default_export_directory(snapshot.home) + ): + _ensure_private_directory(destination.parent) written = write_export( artifact, destination, diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index 8bc452702..fb1037fb7 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -8,6 +8,7 @@ import functools import os import pathlib +import stat import typing as t from textual.app import ComposeResult @@ -73,6 +74,21 @@ def _active_worker_cancelled() -> bool: return False +def _missing_private_directory_is_reviewable(path: pathlib.Path) -> bool: + """Check a missing private path's existing prefix without mutation.""" + absolute = pathlib.Path(os.path.abspath(os.fspath(path))) # noqa: PTH100 + current = pathlib.Path(absolute.anchor) + for component in absolute.parts[1:]: + current /= component + try: + status = current.lstat() + except FileNotFoundError: + return os.access(current.parent, os.W_OK | os.X_OK) + if stat.S_ISLNK(status.st_mode) or not stat.S_ISDIR(status.st_mode): + return False + return False + + def _validate_export_draft( draft: ExportDraft, *, @@ -81,8 +97,6 @@ def _validate_export_draft( home: pathlib.Path, ) -> _ValidationResult: """Validate one immutable draft away from the Textual pump.""" - from agentgrep.record_export import ExportError, _ensure_private_directory - try: filename = render_export_filename( draft.filename_template, @@ -94,17 +108,18 @@ def _validate_export_draft( except ExportPreferencesError: return _ValidationResult(error=_DIRECTORY_ERROR) - if directory == default_export_directory(home): - try: - _ensure_private_directory(directory) - except ExportError: - return _ValidationResult(error=_DIRECTORY_UNAVAILABLE_ERROR) - try: - if directory.is_symlink() or not directory.is_dir(): - return _ValidationResult(error=_DIRECTORY_UNAVAILABLE_ERROR) - if not os.access(directory, os.W_OK | os.X_OK): - return _ValidationResult(error=_DIRECTORY_ACCESS_ERROR) + missing_default = directory == default_export_directory(home) and not os.path.lexists( + directory, + ) + if missing_default: + if not _missing_private_directory_is_reviewable(directory): + return _ValidationResult(error=_DIRECTORY_UNAVAILABLE_ERROR) + else: + if directory.is_symlink() or not directory.is_dir(): + return _ValidationResult(error=_DIRECTORY_UNAVAILABLE_ERROR) + if not os.access(directory, os.W_OK | os.X_OK): + return _ValidationResult(error=_DIRECTORY_ACCESS_ERROR) destination = directory / filename if os.path.lexists(destination): return _ValidationResult(error=_DESTINATION_EXISTS_ERROR) diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 955ba8bd0..c832c15c7 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -143,6 +143,25 @@ def test_export_guide_defines_reviewed_tui_destination() -> None: ) +def test_reviewed_tui_docs_define_save_mutation_boundary() -> None: + """Every reviewed-save contract distinguishes review from durable writes.""" + for relative_path in ( + "docs/dev/adr/0017-portable-record-export.md", + "docs/cli/export.md", + "docs/tui/index.md", + ): + text = _read_text(relative_path) + missing = _missing_terms( + text, + ( + "Save is the mutation boundary", + "No and cancel perform no filesystem mutation", + "TUI-private preference file", + ), + ) + assert not missing, f"{relative_path} is missing {missing!r}" + + def test_export_mcp_docs_define_bounded_inline_contract() -> None: """The MCP guide distinguishes selection from discovery and local writes.""" tools = _read_text("docs/mcp/tools.md") diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index c196efdcf..9d8b71a86 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -217,6 +217,7 @@ async def test_export_shortcut_confirms_selected_record_and_appears_in_keys( await pilot.press("enter", "enter") await _wait_for(lambda: dialog.phase == "review") assert _static_text(dialog, "#export-review-directory") == str(export_dir) + assert not export_dir.exists() await pilot.press("y") await _wait_for( lambda: bool(list(export_dir.glob("*.md"))) or dialog.phase == "edit", diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index ef5033e8f..dfda1dec2 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -7,7 +7,6 @@ import datetime import os import pathlib -import stat import threading import time import typing as t @@ -266,11 +265,13 @@ def observed_access(path: os.PathLike[str], mode: int) -> bool: assert all(thread_id != pump_thread for thread_id in access_threads) -async def test_first_use_default_directory_is_created_privately( +@pytest.mark.parametrize("cancel_key", ("n", "ctrl+c"), ids=("no", "cancel")) +async def test_first_use_default_review_does_not_create_directory( + cancel_key: str, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A clean session can review the app-owned default without pre-creating it.""" + """Review, No, and cancel leave the clean app-owned default absent.""" home = tmp_path / "home" data_home = tmp_path / "data" data_home.mkdir() @@ -287,8 +288,12 @@ async def test_first_use_default_directory_is_created_privately( await _wait_for(pilot, lambda: _dialog(app).phase != "validating") assert _dialog(app).phase == "review" - assert directory.is_dir() - assert stat.S_IMODE(directory.stat().st_mode) == 0o700 + assert not directory.exists() + + await pilot.press(cancel_key) + await pilot.pause() + + assert not directory.exists() async def test_home_default_is_reviewed_as_tilde( From 42139dbf96b5f64443a0980e4b4adc6196e439b7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:01:33 -0500 Subject: [PATCH 59/71] agentgrep(refactor[tui]): Narrow widget exports why: The widgets package re-exported completion rows, popup chrome, and export workflow values that are internal module details rather than reusable widgets. what: - Keep internal candidate, popup, draft, and intent types out of package exports. - Import the intent type directly from its defining module. - Pin the narrower package boundary without making an API claim. --- src/agentgrep/ui/layouts/hud.py | 2 +- src/agentgrep/ui/widgets/__init__.py | 12 ++---------- tests/test_ui_export_dialog.py | 15 +++++++++------ tests/test_ui_export_directory_popup.py | 11 +++++++---- tests/test_ui_export_preferences.py | 6 +++--- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index 4e4777136..1205098ee 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -58,7 +58,6 @@ DetailFocusRequested, DetailScroll, ExportDialog, - ExportIntent, FilterHeader, FilterInput, PaneHeader, @@ -70,6 +69,7 @@ WelcomeExamples, WelcomeQuerySelected, ) +from agentgrep.ui.widgets.export_dialog import ExportIntent from agentgrep.ui.widgets.welcome import ( _WELCOME_BRAND_SHINE, _WELCOME_QUERIES, diff --git a/src/agentgrep/ui/widgets/__init__.py b/src/agentgrep/ui/widgets/__init__.py index 7bf7050c5..ac36b2065 100644 --- a/src/agentgrep/ui/widgets/__init__.py +++ b/src/agentgrep/ui/widgets/__init__.py @@ -12,13 +12,9 @@ import logging from agentgrep.ui.widgets.detail import DetailScroll -from agentgrep.ui.widgets.directory_popup import ( - DirectoryCandidate, - DirectoryCompletionPopup, - ExportDirectoryPicker, -) +from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker from agentgrep.ui.widgets.dropdown import CompletionDropdown -from agentgrep.ui.widgets.export_dialog import ExportDialog, ExportDraft, ExportIntent +from agentgrep.ui.widgets.export_dialog import ExportDialog from agentgrep.ui.widgets.history import HistoryRecall from agentgrep.ui.widgets.inputs import DetailFindInput, FilterInput, SearchInput from agentgrep.ui.widgets.messages import ( @@ -63,12 +59,8 @@ "DetailFocusRequested", "DetailScroll", "DetailScrollChanged", - "DirectoryCandidate", - "DirectoryCompletionPopup", "ExportDialog", "ExportDirectoryPicker", - "ExportDraft", - "ExportIntent", "FilterCompleted", "FilterHeader", "FilterInput", diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index dfda1dec2..1c840b5b7 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -19,8 +19,9 @@ from agentgrep.ui import _runtime, widgets from agentgrep.ui._export_preferences import ExportPreferences, default_export_directory -from agentgrep.ui.widgets import ExportDialog, ExportDraft, ExportIntent +from agentgrep.ui.widgets import ExportDialog from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker +from agentgrep.ui.widgets.export_dialog import ExportDraft, ExportIntent _TIMESTAMP = datetime.datetime(2026, 7, 14, 9, 8, 7, tzinfo=datetime.UTC) @@ -128,13 +129,15 @@ async def _open_review(app: _ExportDialogHost, pilot: Pilot[None]) -> None: await _wait_for(pilot, lambda: _dialog(app).phase == "review") -def test_export_dialog_interfaces_are_available_and_immutable( +def test_export_dialog_interface_is_available_and_internal_values_are_immutable( tmp_path: pathlib.Path, ) -> None: - """The package exports the modal and its immutable boundary values.""" + """The package exports the modal but not its immutable internal values.""" assert widgets.ExportDialog is ExportDialog - assert widgets.ExportDraft is ExportDraft - assert widgets.ExportIntent is ExportIntent + assert "ExportDraft" not in widgets.__all__ + assert "ExportIntent" not in widgets.__all__ + assert not hasattr(widgets, "ExportDraft") + assert not hasattr(widgets, "ExportIntent") draft = ExportDraft(str(tmp_path), "{title}.md", _TIMESTAMP) intent = ExportIntent(tmp_path / "record.md", ExportPreferences(str(tmp_path))) @@ -265,7 +268,7 @@ def observed_access(path: os.PathLike[str], mode: int) -> bool: assert all(thread_id != pump_thread for thread_id in access_threads) -@pytest.mark.parametrize("cancel_key", ("n", "ctrl+c"), ids=("no", "cancel")) +@pytest.mark.parametrize("cancel_key", ["n", "ctrl+c"], ids=("no", "cancel")) async def test_first_use_default_review_does_not_create_directory( cancel_key: str, tmp_path: pathlib.Path, diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index 931b7deff..0d9d4a9f1 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -80,10 +80,13 @@ def _prompts(popup: DirectoryCompletionPopup) -> tuple[str, ...]: return tuple(str(option.prompt) for option in popup.options) -def test_export_directory_picker_interface_is_available_and_immutable() -> None: - """The widgets package exports the owning picker and immutable row type.""" +def test_export_directory_picker_interface_hides_internal_rows() -> None: + """The widgets package exports the picker but not completion internals.""" assert widgets.ExportDirectoryPicker is ExportDirectoryPicker - assert widgets.DirectoryCandidate is DirectoryCandidate + assert "DirectoryCandidate" not in widgets.__all__ + assert "DirectoryCompletionPopup" not in widgets.__all__ + assert not hasattr(widgets, "DirectoryCandidate") + assert not hasattr(widgets, "DirectoryCompletionPopup") assert issubclass(DirectoryCompletionPopup, OptionList) candidate = DirectoryCandidate(value="./alpha/", label="alpha") mutable_candidate = t.cast("t.Any", candidate) @@ -356,7 +359,7 @@ def test_candidate_labels_are_basenames_and_values_preserve_prefix( assert result.values == (DirectoryCandidate(value=expected, label="alpha"),) -@pytest.mark.parametrize("unsafe", ("\u200b", "\u202e")) +@pytest.mark.parametrize("unsafe", ["\u200b", "\u202e"]) def test_completion_omits_unreviewable_directory_names( unsafe: str, tmp_path: pathlib.Path, diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index bb734fdaf..549805ffc 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -124,7 +124,7 @@ def test_resolve_export_directory_rejects_other_users( resolve_export_directory("~other/Exports", tmp_path / "home") -@pytest.mark.parametrize("unsafe", ("\n", "\u202e", "\ud800")) +@pytest.mark.parametrize("unsafe", ["\n", "\u202e", "\ud800"]) def test_resolve_export_directory_rejects_unreviewable_unicode( unsafe: str, tmp_path: pathlib.Path, @@ -358,7 +358,7 @@ def test_invalid_export_preferences_return_defaults_with_warning( assert loaded.warning == "Export preferences could not be read" -@pytest.mark.parametrize("unsafe", ("\n", "\u202e", "\ud800")) +@pytest.mark.parametrize("unsafe", ["\n", "\u202e", "\ud800"]) def test_unreviewable_directory_preferences_are_not_loaded( unsafe: str, tmp_path: pathlib.Path, @@ -390,7 +390,7 @@ def test_unreviewable_directory_preferences_are_not_loaded( ) -@pytest.mark.parametrize("unsafe", ("\n", "\u202e", "\ud800")) +@pytest.mark.parametrize("unsafe", ["\n", "\u202e", "\ud800"]) def test_unreviewable_directory_preferences_are_not_saved( unsafe: str, tmp_path: pathlib.Path, From e10e086b115afffaa2128c359efa43bb80a34ef0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:02:29 -0500 Subject: [PATCH 60/71] agentgrep(docs[tui]): Clarify export key focus why: The export changelog used an ambiguous pronoun and implied that every input state handled the shortcut identically. what: - State directly that e remains ordinary text when an input is focused. - Pin the exact focused-input wording in the export documentation contract. --- CHANGES | 4 ++-- tests/test_export_docs.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index 74fd83cbd..43ea3018b 100644 --- a/CHANGES +++ b/CHANGES @@ -80,8 +80,8 @@ human-readable Markdown without changing the underlying histories. The CLI exports matching records to standard output or a chosen file, the HUD exports one selected record or its observed thread, and MCP returns a bounded inline artifact for existing search refs. In the HUD, `e` captures the selected record -from the results list or detail pane, then opens a compact destination review; -it remains ordinary text in inputs. +from the results list or detail pane, then opens a compact destination review. +`e` remains ordinary text when an input is focused. The review remembers its directory and filename template, previews a filesystem-safe local timestamp with a bounded title, and keeps the draft when diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index c832c15c7..3d7ff9be9 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -51,6 +51,13 @@ def test_export_docs_are_indexed() -> None: assert "(adr-portable-record-export)=" in adr +def test_export_changelog_clarifies_focused_input_key() -> None: + """The shortcut copy scopes ordinary ``e`` text to focused inputs.""" + changes = _read_text("CHANGES") + + assert "`e` remains ordinary text when an input is focused." in changes + + def test_export_cli_docs_define_defaults_and_safe_sinks() -> None: """The headless guide names exact formats, bounds, bodies, and sinks.""" guide = _read_text("docs/cli/export.md") From 18e547433f600b9fde5a75e162e67ae2e5c6ea4c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:03:26 -0500 Subject: [PATCH 61/71] agentgrep(fix[tui]): Reject empty export path why: An empty directory draft resolved to the process working directory, so review could display a blank value while Save targeted an implicit location. what: - Reject empty text in the shared directory validator while preserving literal whitespace-only Unix paths. - Cover resolution, preference load and save, dialog review, and completion boundaries. --- src/agentgrep/ui/_export_preferences.py | 9 +++++-- tests/test_ui_export_dialog.py | 16 ++++++++++++ tests/test_ui_export_directory_popup.py | 23 +++++++++++++++++ tests/test_ui_export_preferences.py | 33 +++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index f36e00f6c..45cd35f73 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -73,8 +73,13 @@ class ExportPreferencesError(Exception): def _validate_directory_value(value: str) -> None: """Reject directory text that cannot be reviewed reliably.""" - if not isinstance(value, str) or any( - unicodedata.category(character) in _UNREVIEWABLE_UNICODE_CATEGORIES for character in value + if ( + not isinstance(value, str) + or not value + or any( + unicodedata.category(character) in _UNREVIEWABLE_UNICODE_CATEGORIES + for character in value + ) ): raise ExportPreferencesError(_DIRECTORY_ERROR) diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 1c840b5b7..3590a0212 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -361,6 +361,22 @@ async def test_submitted_absolute_home_directory_is_compacted( assert _text(app, "#export-review-directory") == "~/Exports" +async def test_empty_directory_cannot_reach_review(tmp_path: pathlib.Path) -> None: + """A cleared directory stays in edit with a path-free validation error.""" + seen: list[ExportIntent] = [] + app = _ExportDialogHost(tmp_path, lambda intent: seen.append(intent) or True) + async with app.run_test(size=(60, 16)) as pilot: + picker = app.screen.query_one("#export-directory", ExportDirectoryPicker) + picker.value = "" + + await pilot.press("enter", "enter") + await _wait_for(pilot, lambda: _dialog(app).phase != "validating") + + assert _dialog(app).phase == "edit" + assert _text(app, "#export-error") == "Export directory is invalid" + assert seen == [] + + async def test_missing_arbitrary_directory_is_not_created(tmp_path: pathlib.Path) -> None: """Validation never creates a missing user-entered directory tree.""" directory = tmp_path / "missing" / "arbitrary" diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index 0d9d4a9f1..072848927 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -95,6 +95,29 @@ def test_export_directory_picker_interface_hides_internal_rows() -> None: mutable_candidate.label = "changed" +def test_empty_directory_value_has_no_completion_candidates( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Clearing the directory closes completion without scanning the cwd.""" + unexpected_scan = "empty completion must not scan a directory" + + def fail_scandir(_path: os.PathLike[str]) -> t.NoReturn: + raise AssertionError(unexpected_scan) + + monkeypatch.setattr(directory_popup.os, "scandir", fail_scandir) + + result = directory_popup._enumerate_directory_candidates( + "", + home=tmp_path / "home", + candidate_limit=DIRECTORY_CANDIDATE_LIMIT, + scan_limit=DIRECTORY_SCAN_LIMIT, + ) + + assert result.values == () + assert result.truncated is False + + async def test_directory_enumeration_waits_for_inactivity( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index 549805ffc..8bffd143d 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -124,6 +124,17 @@ def test_resolve_export_directory_rejects_other_users( resolve_export_directory("~other/Exports", tmp_path / "home") +def test_resolve_export_directory_rejects_empty_value( + tmp_path: pathlib.Path, +) -> None: + """A cleared directory cannot silently resolve to the process directory.""" + with pytest.raises( + ExportPreferencesError, + match=r"^Export directory is invalid$", + ): + resolve_export_directory("", tmp_path / "home") + + @pytest.mark.parametrize("unsafe", ["\n", "\u202e", "\ud800"]) def test_resolve_export_directory_rejects_unreviewable_unicode( unsafe: str, @@ -331,6 +342,7 @@ def test_missing_export_preferences_return_defaults_without_warning( b'{"version":2,"directory":"~/Exports","filename_template":"{title}.md"}', b'{"version":true,"directory":"~/Exports","filename_template":"{title}.md"}', b'{"version":1,"directory":[],"filename_template":"{title}.md"}', + b'{"version":1,"directory":"","filename_template":"{title}.md"}', b'{"version":1,"directory":"~/Exports","filename_template":2}', b'{"version":1,"directory":"~/Exports","filename_template":"{title}.md","extra":1}', b'{"version":1,"version":1,"directory":"~/Exports","filename_template":"{title}.md"}', @@ -413,6 +425,27 @@ def test_unreviewable_directory_preferences_are_not_saved( assert not export_preferences_path(tmp_path / "home").exists() +def test_empty_directory_preferences_are_not_saved( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A cleared directory is rejected before the preference file is created.""" + config_home = tmp_path / "config" + config_home.mkdir() + monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) + + with pytest.raises( + ExportPreferencesError, + match=r"^Export preferences could not be saved$", + ): + save_export_preferences( + tmp_path / "home", + ExportPreferences(directory=""), + ) + + assert not export_preferences_path(tmp_path / "home").exists() + + def test_export_preferences_fifo_returns_promptly_with_path_free_warning( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, From 0eb2fc8b75733a3e80c09da2a3ed679ecd078b26 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:05:08 -0500 Subject: [PATCH 62/71] agentgrep(fix[tui]): Bound export directory why: Lexical directory compaction runs on the Textual pump, where an unbounded path with many components could stall input handling before validation was offloaded. what: - Apply a documented 4,096-character ceiling to the live editor and shared validation and persistence boundaries. - Reject oversized Enter snapshots and compaction inputs before path processing, with boundary regressions. --- src/agentgrep/ui/_export_preferences.py | 6 ++ src/agentgrep/ui/widgets/directory_popup.py | 7 ++- src/agentgrep/ui/widgets/export_dialog.py | 13 ++-- tests/test_ui_export_dialog.py | 29 ++++++++- tests/test_ui_export_directory_popup.py | 4 ++ tests/test_ui_export_preferences.py | 66 ++++++++++++++++++++- 6 files changed, 115 insertions(+), 10 deletions(-) diff --git a/src/agentgrep/ui/_export_preferences.py b/src/agentgrep/ui/_export_preferences.py index 45cd35f73..521c6e630 100644 --- a/src/agentgrep/ui/_export_preferences.py +++ b/src/agentgrep/ui/_export_preferences.py @@ -20,6 +20,8 @@ DEFAULT_FILENAME_TEMPLATE = "{date} {time} - {title}.md" MAX_PREFERENCES_BYTES = 16 * 1024 +# Practical UI ceiling that accommodates common PATH_MAX-sized ASCII paths. +MAX_DIRECTORY_CHARS = 4096 MAX_TEMPLATE_CHARS = 256 MAX_FILENAME_BYTES = 180 @@ -35,6 +37,7 @@ __all__ = [ "DEFAULT_FILENAME_TEMPLATE", + "MAX_DIRECTORY_CHARS", "MAX_FILENAME_BYTES", "MAX_PREFERENCES_BYTES", "MAX_TEMPLATE_CHARS", @@ -76,6 +79,7 @@ def _validate_directory_value(value: str) -> None: if ( not isinstance(value, str) or not value + or len(value) > MAX_DIRECTORY_CHARS or any( unicodedata.category(character) in _UNREVIEWABLE_UNICODE_CATEGORIES for character in value @@ -100,6 +104,8 @@ def compact_export_directory(value: str, home: pathlib.Path) -> str: ``~`` or a current-user tilde path for values under ``home``; otherwise the original literal value. """ + if len(value) > MAX_DIRECTORY_CHARS: + raise ExportPreferencesError(_DIRECTORY_ERROR) candidate = pathlib.Path(value) normalized_home = pathlib.Path(os.path.normpath(os.fspath(home))) if not candidate.is_absolute() or not normalized_home.is_absolute(): diff --git a/src/agentgrep/ui/widgets/directory_popup.py b/src/agentgrep/ui/widgets/directory_popup.py index c89566621..1641d2a56 100644 --- a/src/agentgrep/ui/widgets/directory_popup.py +++ b/src/agentgrep/ui/widgets/directory_popup.py @@ -20,6 +20,7 @@ from agentgrep.ui import _runtime from agentgrep.ui._export_preferences import ( + MAX_DIRECTORY_CHARS, ExportPreferencesError, _validate_directory_value, resolve_export_directory, @@ -165,7 +166,11 @@ class _DirectoryPathInput(Input): def __init__(self, owner: ExportDirectoryPicker, *, value: str) -> None: self._owner = owner - super().__init__(value=value, placeholder="Export directory") + super().__init__( + value=value, + placeholder="Export directory", + max_length=MAX_DIRECTORY_CHARS, + ) @_runtime.pump_only def on_focus(self) -> None: diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index fb1037fb7..034c7c405 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -21,6 +21,7 @@ from agentgrep.ui import _runtime from agentgrep.ui._export_preferences import ( + MAX_DIRECTORY_CHARS, ExportPreferences, ExportPreferencesError, compact_export_directory, @@ -391,13 +392,17 @@ def _invalidate_error_reveal(self) -> None: @_runtime.pump_only def _start_validation(self) -> None: """Snapshot the draft and launch one exclusive validator worker.""" - if not self._refresh_preview(): - return template = self.query_one("#export-template", Input) picker = self.query_one("#export-directory", ExportDirectoryPicker) self._edit_focus = "template" if template.has_focus else "directory" - directory = compact_export_directory(picker.value, self._home) - if directory != picker.value: + directory_value = picker.value + if not directory_value or len(directory_value) > MAX_DIRECTORY_CHARS: + self._update_error(_DIRECTORY_ERROR) + return + if not self._refresh_preview(): + return + directory = compact_export_directory(directory_value, self._home) + if directory != directory_value: picker.value = directory draft = ExportDraft( directory=directory, diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 3590a0212..09c980020 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -17,9 +17,9 @@ from textual.pilot import Pilot from textual.widgets import Input, OptionList, Static -from agentgrep.ui import _runtime, widgets +from agentgrep.ui import _export_preferences as export_preferences, _runtime, widgets from agentgrep.ui._export_preferences import ExportPreferences, default_export_directory -from agentgrep.ui.widgets import ExportDialog +from agentgrep.ui.widgets import ExportDialog, export_dialog from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker from agentgrep.ui.widgets.export_dialog import ExportDraft, ExportIntent @@ -377,6 +377,31 @@ async def test_empty_directory_cannot_reach_review(tmp_path: pathlib.Path) -> No assert seen == [] +async def test_over_bound_directory_stops_before_compaction( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Enter rejects oversized text on the pump before compaction is reached.""" + seen: list[ExportIntent] = [] + app = _ExportDialogHost(tmp_path, lambda intent: seen.append(intent) or True) + async with app.run_test(size=(60, 16)) as pilot: + picker = app.screen.query_one("#export-directory", ExportDirectoryPicker) + picker.value = "x" * (export_preferences.MAX_DIRECTORY_CHARS + 1) + unexpected = "oversized Enter reached directory compaction" + + def fail_compaction(_value: str, _home: pathlib.Path) -> t.NoReturn: + raise AssertionError(unexpected) + + monkeypatch.setattr(export_dialog, "compact_export_directory", fail_compaction) + + await pilot.press("enter", "enter") + await pilot.pause() + + assert _dialog(app).phase == "edit" + assert _text(app, "#export-error") == "Export directory is invalid" + assert seen == [] + + async def test_missing_arbitrary_directory_is_not_created(tmp_path: pathlib.Path) -> None: """Validation never creates a missing user-entered directory tree.""" directory = tmp_path / "missing" / "arbitrary" diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index 072848927..d1de34c83 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -94,6 +94,10 @@ def test_export_directory_picker_interface_hides_internal_rows() -> None: with pytest.raises(dataclasses.FrozenInstanceError): mutable_candidate.label = "changed" + picker = ExportDirectoryPicker(value="", home=pathlib.Path("home")) + path_input = t.cast("t.Any", picker)._input + assert path_input.max_length == directory_popup.MAX_DIRECTORY_CHARS + def test_empty_directory_value_has_no_completion_candidates( tmp_path: pathlib.Path, diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index 8bffd143d..392e1771e 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -33,6 +33,11 @@ DEFAULT_TEMPLATE = "{date} {time} - {title}.md" +def test_directory_draft_character_bound_is_portable() -> None: + """Directory drafts use a conservative cross-platform practical bound.""" + assert export_preferences.MAX_DIRECTORY_CHARS == 4096 + + def test_export_preferences_path_follows_xdg_config_home( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -135,6 +140,20 @@ def test_resolve_export_directory_rejects_empty_value( resolve_export_directory("", tmp_path / "home") +def test_resolve_export_directory_enforces_character_bound( + tmp_path: pathlib.Path, +) -> None: + """The shared directory validator accepts the ceiling and rejects one more.""" + at_limit = "x" * export_preferences.MAX_DIRECTORY_CHARS + + assert resolve_export_directory(at_limit, tmp_path / "home") == pathlib.Path(at_limit) + with pytest.raises( + ExportPreferencesError, + match=r"^Export directory is invalid$", + ): + resolve_export_directory(f"{at_limit}x", tmp_path / "home") + + @pytest.mark.parametrize("unsafe", ["\n", "\u202e", "\ud800"]) def test_resolve_export_directory_rejects_unreviewable_unicode( unsafe: str, @@ -179,6 +198,30 @@ def reject_resolve(_path: pathlib.Path, *_args: object, **_kwargs: object) -> t. ) +def test_compact_export_directory_rejects_over_bound_before_normalizing( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Oversized compaction stops before constructing or normalizing a path.""" + unexpected = "oversized directory reached path normalization" + + def fail_path(*_args: object, **_kwargs: object) -> t.NoReturn: + raise AssertionError(unexpected) + + with monkeypatch.context() as context: + context.setattr(export_preferences.pathlib, "Path", fail_path) + context.setattr(export_preferences.os.path, "normpath", fail_path) + + with pytest.raises( + ExportPreferencesError, + match=r"^Export directory is invalid$", + ): + export_preferences.compact_export_directory( + "x" * (export_preferences.MAX_DIRECTORY_CHARS + 1), + tmp_path / "home", + ) + + def test_default_export_filename_is_frozen_local_ascii() -> None: """The default template compiles to the reviewed local-time basename.""" when = datetime.datetime(2026, 7, 14, 9, 8, 7).astimezone() @@ -343,6 +386,17 @@ def test_missing_export_preferences_return_defaults_without_warning( b'{"version":true,"directory":"~/Exports","filename_template":"{title}.md"}', b'{"version":1,"directory":[],"filename_template":"{title}.md"}', b'{"version":1,"directory":"","filename_template":"{title}.md"}', + pytest.param( + json.dumps( + { + "version": 1, + "directory": "x" * (export_preferences.MAX_DIRECTORY_CHARS + 1), + "filename_template": "{title}.md", + }, + separators=(",", ":"), + ).encode(), + id="over-bound-directory", + ), b'{"version":1,"directory":"~/Exports","filename_template":2}', b'{"version":1,"directory":"~/Exports","filename_template":"{title}.md","extra":1}', b'{"version":1,"version":1,"directory":"~/Exports","filename_template":"{title}.md"}', @@ -425,11 +479,17 @@ def test_unreviewable_directory_preferences_are_not_saved( assert not export_preferences_path(tmp_path / "home").exists() -def test_empty_directory_preferences_are_not_saved( +@pytest.mark.parametrize( + "directory", + ["", "x" * (export_preferences.MAX_DIRECTORY_CHARS + 1)], + ids=("empty", "over-bound"), +) +def test_invalid_directory_preferences_are_not_saved( + directory: str, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """A cleared directory is rejected before the preference file is created.""" + """Invalid directory text is rejected before the preference file is created.""" config_home = tmp_path / "config" config_home.mkdir() monkeypatch.setenv("XDG_CONFIG_HOME", str(config_home)) @@ -440,7 +500,7 @@ def test_empty_directory_preferences_are_not_saved( ): save_export_preferences( tmp_path / "home", - ExportPreferences(directory=""), + ExportPreferences(directory=directory), ) assert not export_preferences_path(tmp_path / "home").exists() From dbac271c9d745bf131fad283e45b9b0b6f4bd632 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:09:01 -0500 Subject: [PATCH 63/71] agentgrep(feat[tui]): Polish export dialog why: Export editing should follow the directional muscle memory of the rest of the TUI, while review should remain calm and legible in small terminals. what: - Add clamped Ctrl-H/J/K/L and completion-aware arrow traversal. - Pin edit and review hints without hiding validation feedback. - Shrink-wrap a No-first confirmation with a moving selection marker. - Cover navigation, layout, saving, and pump roles with Pilot tests. --- src/agentgrep/ui/styles.tcss | 13 +- src/agentgrep/ui/widgets/directory_popup.py | 10 +- src/agentgrep/ui/widgets/export_dialog.py | 68 ++++++++- tests/test_ui_export_dialog.py | 145 +++++++++++++++++++- tests/test_ui_export_directory_popup.py | 2 + 5 files changed, 224 insertions(+), 14 deletions(-) diff --git a/src/agentgrep/ui/styles.tcss b/src/agentgrep/ui/styles.tcss index be1c07abd..f1afab6f7 100644 --- a/src/agentgrep/ui/styles.tcss +++ b/src/agentgrep/ui/styles.tcss @@ -693,6 +693,10 @@ ExportDialog { color: $accent; text-style: bold; } +#export-review-title { + color: $text; + text-style: bold; +} #export-review-directory { color: $text; } @@ -710,9 +714,12 @@ ExportDialog { background-tint: $foreground 0%; padding: 0; } +#export-confirm > .option-list--option { + color: $ag-muted; +} #export-confirm > .option-list--option-highlighted, #export-confirm:focus > .option-list--option-highlighted { - color: auto; - background: $ag-state-selected-bg; - text-style: none; + color: $accent; + background: transparent; + text-style: bold; } diff --git a/src/agentgrep/ui/widgets/directory_popup.py b/src/agentgrep/ui/widgets/directory_popup.py index 1641d2a56..b1a6f051b 100644 --- a/src/agentgrep/ui/widgets/directory_popup.py +++ b/src/agentgrep/ui/widgets/directory_popup.py @@ -189,8 +189,9 @@ def action_directory_up(self) -> None: @_runtime.pump_only def action_directory_down(self) -> None: - """Move to the next visible completion.""" - self._owner._move_highlight(1) + """Move through completion or to the next export field.""" + if not self._owner._move_highlight(1): + self.screen.focus_next(Input) @_runtime.pump_only def action_cursor_right(self, select: bool = False) -> None: @@ -386,14 +387,15 @@ def _invalidate_completion(self, *, cancel_worker: bool = False) -> None: self._popup.display = False @_runtime.pump_only - def _move_highlight(self, step: int) -> None: + def _move_highlight(self, step: int) -> bool: """Move through selectable completion rows with wraparound.""" if not self._popup.display or not self._candidate_values: - return + return False if step < 0: self._popup.action_cursor_up() else: self._popup.action_cursor_down() + return True @_runtime.pump_only def _accept_highlighted(self) -> bool: diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_dialog.py index 034c7c405..c239be729 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_dialog.py @@ -38,6 +38,7 @@ _DIRECTORY_UNAVAILABLE_ERROR = "Export directory is unavailable" _DIRECTORY_ACCESS_ERROR = "Export directory is not writable" _DESTINATION_EXISTS_ERROR = "Export destination already exists" +_REVIEW_HINT = "↑↓ move · Enter · Esc edit" ExportPhase = t.Literal["edit", "validating", "review", "saving"] @@ -144,6 +145,12 @@ class ExportDialog(ModalScreen[None]): BINDINGS: t.ClassVar[list[Binding]] = [ Binding("escape", "escape", "Back / Cancel", priority=True, show=False), Binding("ctrl+c", "cancel", "Cancel", priority=True, show=False), + Binding("ctrl+h", "editor_previous", "Previous field", priority=True, show=False), + Binding("ctrl+j", "editor_next", "Next field", priority=True, show=False), + Binding("ctrl+k", "editor_previous", "Previous field", priority=True, show=False), + Binding("ctrl+l", "editor_next", "Next field", priority=True, show=False), + Binding("up", "editor_previous", "Previous field", show=False), + Binding("down", "editor_next", "Next field", show=False), Binding("n", "review_no", "No", show=False), Binding("y", "review_save", "Save", show=False), ] @@ -181,11 +188,26 @@ class ExportDialog(ModalScreen[None]): width: 100%; height: 1; } + #export-edit-footer { + dock: bottom; + } + #export-review-title { + width: 100%; + height: 1; + } + #export-review-status { + dock: bottom; + } #export-review { display: none; } + #export-dialog.-reviewing, + #export-dialog.-reviewing #export-review { + height: auto; + max-height: 12; + } #export-confirm { - width: 100%; + width: 12; height: 2; } """ @@ -247,11 +269,12 @@ def compose(self) -> ComposeResult: markup=False, ) with VerticalScroll(id="export-review"): + yield Static("Save this export?", id="export-review-title", markup=False) yield Static("Directory", classes="export-label") yield Static("", id="export-review-directory", markup=False) yield Static("Filename", classes="export-label") yield Static("", id="export-review-filename", markup=False) - yield OptionList("No", "Save", id="export-confirm", markup=False, compact=True) + yield OptionList("→ No", " Save", id="export-confirm", markup=False, compact=True) yield Static("", id="export-review-status", markup=False) @_runtime.pump_only @@ -297,6 +320,12 @@ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> No elif event.option_index == 1: self._confirm() + @_runtime.pump_only + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + """Move the quiet review marker with the active confirmation row.""" + if event.option_list.id == "export-confirm": + self._update_review_choices(event.option_index) + @_runtime.pump_only def action_escape(self) -> None: """Return from review or cancel before a durable save begins.""" @@ -325,6 +354,27 @@ def action_cancel(self) -> None: return self._dismiss_dialog() + @_runtime.pump_only + def action_editor_previous(self) -> None: + """Move to the previous editor without wrapping at the first field.""" + if self._phase != "edit": + return + template = self.query_one("#export-template", Input) + if template.has_focus: + self.query_one("#export-directory", ExportDirectoryPicker).focus_input() + + @_runtime.pump_only + def action_editor_next(self) -> None: + """Move to the next editor without wrapping at the final field.""" + if self._phase != "edit": + return + directory = self.query_one( + "#export-directory", + ExportDirectoryPicker, + ).query_one(Input) + if directory.has_focus: + self.query_one("#export-template", Input).focus() + @_runtime.pump_only def action_review_no(self) -> None: """Return to the retained draft only while reviewing.""" @@ -477,6 +527,7 @@ def _show_edit(self, error: str | None = None) -> None: """Restore the retained edit stage and its prior focus.""" self._phase = "edit" self._intent = None + self.query_one("#export-dialog", Vertical).remove_class("-reviewing") edit = self.query_one("#export-edit", VerticalScroll) review = self.query_one("#export-review", VerticalScroll) edit.display = True @@ -522,6 +573,7 @@ def _show_review(self, intent: ExportIntent) -> None: """Show the literal directory and exact basename with No selected.""" self._invalidate_error_reveal() self._phase = "review" + self.query_one("#export-dialog", Vertical).add_class("-reviewing") self.query_one("#export-edit", VerticalScroll).display = False self.query_one("#export-review", VerticalScroll).display = True self.query_one("#export-review-directory", Static).update( @@ -531,12 +583,22 @@ def _show_review(self, intent: ExportIntent) -> None: Content(intent.destination.name), ) status = self.query_one("#export-review-status", Static) - status.update(Content("")) + status.update(Content(_REVIEW_HINT)) confirm = self.query_one("#export-confirm", OptionList) confirm.disabled = False confirm.highlighted = 0 + self._update_review_choices(0) confirm.focus() + @_runtime.pump_only + def _update_review_choices(self, highlighted: int) -> None: + """Render one Pi-like arrow without changing option identity.""" + confirm = self.query_one("#export-confirm", OptionList) + for index, label in enumerate(("No", "Save")): + marker = "→" if index == highlighted else " " + confirm.replace_option_prompt_at_index(index, f"{marker} {label}") + confirm.refresh() + @_runtime.pump_only def _confirm(self) -> None: """Delegate once and retain the modal while the writer is active.""" diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 09c980020..425b7f5ad 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -147,13 +147,15 @@ def test_export_dialog_interface_is_available_and_internal_values_are_immutable( t.cast("t.Any", intent).destination = tmp_path / "changed.md" -def test_review_letters_are_non_priority_bindings() -> None: - """Focused editors receive ``n`` and ``y`` before review shortcuts.""" +def test_dialog_binding_priorities_preserve_focused_controls() -> None: + """Only modal gestures that must preempt an editor receive priority.""" bindings = {binding.key: binding for binding in ExportDialog.BINDINGS} assert bindings["n"].priority is False assert bindings["y"].priority is False assert bindings["ctrl+c"].priority is True + assert all(bindings[key].priority is True for key in ("ctrl+h", "ctrl+j", "ctrl+k", "ctrl+l")) + assert all(bindings[key].priority is False for key in ("up", "down")) async def test_preview_is_frozen_literal_and_uses_no_filesystem( @@ -199,6 +201,66 @@ async def test_enter_moves_directory_to_template(tmp_path: pathlib.Path) -> None assert _dialog(app).phase == "edit" +@pytest.mark.parametrize( + ("key", "start", "destination"), + [ + ("ctrl+h", "template", "directory"), + ("ctrl+k", "template", "directory"), + ("ctrl+j", "directory", "template"), + ("ctrl+l", "directory", "template"), + ("up", "template", "directory"), + ("down", "directory", "template"), + ], +) +async def test_directional_keys_traverse_and_clamp_without_editing( + key: str, + start: str, + destination: str, + tmp_path: pathlib.Path, +) -> None: + """Directional gestures move once, preserve values, and stop at the edge.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + directory = app.screen.query_one("#export-directory", ExportDirectoryPicker).query_one( + Input, + ) + template = app.screen.query_one("#export-template", Input) + fields = {"directory": directory, "template": template} + values = {name: field.value for name, field in fields.items()} + fields[start].focus() + await pilot.pause() + + await pilot.press(key) + + assert fields[destination].has_focus + assert {name: field.value for name, field in fields.items()} == values + assert _dialog(app).phase == "edit" + + await pilot.press(key) + + assert fields[destination].has_focus + assert {name: field.value for name, field in fields.items()} == values + assert _dialog(app).phase == "edit" + + +async def test_left_right_remain_native_template_cursor_keys(tmp_path: pathlib.Path) -> None: + """Bare horizontal arrows edit the cursor instead of traversing fields.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + template = app.screen.query_one("#export-template", Input) + template.value = "abcd" + template.focus() + await pilot.pause() + template.cursor_position = 2 + + await pilot.press("left") + assert template.has_focus + assert template.cursor_position == 1 + await pilot.press("right") + assert template.has_focus + assert template.cursor_position == 2 + + async def test_directory_input_receives_n_and_y(tmp_path: pathlib.Path) -> None: """Review shortcut letters remain ordinary text in the directory editor.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -497,6 +559,51 @@ async def test_review_shows_directory_and_filename_literally(tmp_path: pathlib.P assert confirm.highlighted == 0 +async def test_review_uses_compact_pi_confirmation_layout(tmp_path: pathlib.Path) -> None: + """Review presents one quiet question, compact choices, and a fixed hint.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + review = app.screen.query_one("#export-review", VerticalScroll) + dialog_body = app.screen.query_one("#export-dialog") + confirm = app.screen.query_one("#export-confirm", OptionList) + status = app.screen.query_one("#export-review-status", Static) + + assert _text(app, "#export-review-title") == "Save this export?" + assert tuple(str(option.prompt) for option in confirm.options) == ( + "→ No", + " Save", + ) + assert confirm.region.width <= 12 + assert "-reviewing" in dialog_body.classes + assert dialog_body.region.height <= 12 + assert status.styles.dock == "bottom" + assert status.region.bottom == review.region.bottom + assert _text(app, "#export-review-status") == ("↑↓ move · Enter · Esc edit") + + +async def test_review_up_down_still_select_confirmation_rows(tmp_path: pathlib.Path) -> None: + """Edit-stage traversal leaves review-list arrows unchanged.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=(60, 16)) as pilot: + await _open_review(app, pilot) + confirm = app.screen.query_one("#export-confirm", OptionList) + + await pilot.press("down") + assert confirm.highlighted == 1 + assert tuple(str(option.prompt) for option in confirm.options) == ( + " No", + "→ Save", + ) + await pilot.press("up") + assert confirm.highlighted == 0 + assert tuple(str(option.prompt) for option in confirm.options) == ( + "→ No", + " Save", + ) + assert _dialog(app).phase == "review" + + async def test_no_returns_to_editor_without_losing_values( tmp_path: pathlib.Path, ) -> None: @@ -556,6 +663,11 @@ async def test_y_invokes_once_and_enters_saving(tmp_path: pathlib.Path) -> None: confirm = app.screen.query_one("#export-confirm", OptionList) assert confirm.highlighted == 1 assert confirm.disabled is True + assert tuple(str(option.prompt) for option in confirm.options) == ( + " No", + "→ Save", + ) + assert _text(app, "#export-review-status") == "Saving…" assert len(seen) == 1 assert seen[0] == ExportIntent( destination=(tmp_path / "2026-07-14 09-08-07 - machine-readable-title.md"), @@ -752,6 +864,25 @@ async def test_dialog_fits_compact_terminal_without_horizontal_scroll( assert review.show_vertical_scrollbar is False +@pytest.mark.parametrize("size", [(60, 16), (30, 10)]) +async def test_edit_footer_is_docked_without_copy_change( + tmp_path: pathlib.Path, + size: tuple[int, int], +) -> None: + """The established edit hint stays pinned to the viewport bottom.""" + app = _ExportDialogHost(tmp_path, lambda _intent: True) + async with app.run_test(size=size) as pilot: + await pilot.pause() + edit = app.screen.query_one("#export-edit", VerticalScroll) + footer = app.screen.query_one("#export-edit-footer", Static) + + assert footer.styles.dock == "bottom" + assert footer.region.bottom == edit.region.bottom + assert _text(app, "#export-edit-footer") == ( + "Tab to move · Enter to review · Ctrl-C to cancel" + ) + + @pytest.mark.parametrize("size", [(40, 12), (30, 10)]) async def test_invalid_template_error_is_visible_in_small_terminal( size: tuple[int, int], @@ -766,10 +897,13 @@ async def test_invalid_template_error_is_visible_in_small_terminal( await pilot.press("enter") await pilot.pause() error = app.screen.query_one("#export-error", Static) + edit = app.screen.query_one("#export-edit", VerticalScroll) + footer = app.screen.query_one("#export-edit-footer", Static) assert _text(app, "#export-error") == "Export filename is invalid" assert error.region.y >= 0 - assert error.region.bottom <= size[1] + assert error.region.bottom <= footer.region.y + assert footer.region.bottom == edit.region.bottom assert template.has_focus @@ -783,10 +917,13 @@ async def test_review_and_edit_are_reachable_in_small_terminal( async with app.run_test(size=size) as pilot: await _open_review(app, pilot) confirm = app.screen.query_one("#export-confirm", OptionList) + review = app.screen.query_one("#export-review", VerticalScroll) + status = app.screen.query_one("#export-review-status", Static) assert confirm.has_focus assert confirm.region.y >= 0 - assert confirm.region.bottom <= size[1] + assert confirm.region.bottom <= status.region.y + assert status.region.bottom == review.region.bottom await pilot.press("n") template = app.screen.query_one("#export-template", Input) diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index d1de34c83..bb60d173e 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -459,8 +459,10 @@ async def test_up_down_wrap_and_right_accepts_only_at_end(tmp_path: pathlib.Path await pilot.press("up") assert popup.highlighted == 1 + assert field.has_focus await pilot.press("down") assert popup.highlighted == 0 + assert field.has_focus original = picker.value field.cursor_position = len(original) - 1 From e842cfb2ba009889dbe054aef04928de49094062 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:13:56 -0500 Subject: [PATCH 64/71] agentgrep(refactor[tui]): Move export to pane why: Modal export obscured the active explorer and made the reviewed save flow hard to distinguish from the reader. A pane-owned state machine keeps context visible and gives shortcut and slash-command entry one behavior. what: - Replace the selected-record modal with a fresh detail-pane export flow. - Restore the exact search draft, selection, focus, zoom, and reader state. - Keep validation and durable output off the Textual message pump. - Document and test the reviewed /export contract and compact layout. --- CHANGES | 7 +- docs/cli/export.md | 32 +- docs/dev/adr/0017-portable-record-export.md | 43 +-- docs/tui/index.md | 49 ++-- src/agentgrep/ui/commands.py | 5 +- src/agentgrep/ui/layouts/_base.py | 12 +- src/agentgrep/ui/layouts/hud.py | 230 ++++++++++++--- src/agentgrep/ui/styles.tcss | 28 +- src/agentgrep/ui/widgets/__init__.py | 4 +- .../{export_dialog.py => export_pane.py} | 100 ++++--- src/agentgrep/ui/widgets/inputs.py | 37 +++ tests/test_export_docs.py | 21 +- tests/test_ui_export.py | 101 ++++--- tests/test_ui_export_dialog.py | 97 ++++--- tests/test_ui_export_pane.py | 273 ++++++++++++++++++ 15 files changed, 799 insertions(+), 240 deletions(-) rename src/agentgrep/ui/widgets/{export_dialog.py => export_pane.py} (89%) create mode 100644 tests/test_ui_export_pane.py diff --git a/CHANGES b/CHANGES index 43ea3018b..7816a7bb7 100644 --- a/CHANGES +++ b/CHANGES @@ -79,9 +79,10 @@ agentgrep can now turn selected search records into deterministic NDJSON or human-readable Markdown without changing the underlying histories. The CLI exports matching records to standard output or a chosen file, the HUD exports one selected record or its observed thread, and MCP returns a bounded inline -artifact for existing search refs. In the HUD, `e` captures the selected record -from the results list or detail pane, then opens a compact destination review. -`e` remains ordinary text when an input is focused. +artifact for existing search refs. In the HUD, `e` or transient +`/export [PATH]` captures the selected record, then replaces the right detail +region with a compact destination review without discarding the current search +or reader state. `e` remains ordinary text when an input is focused. The review remembers its directory and filename template, previews a filesystem-safe local timestamp with a bounded title, and keeps the draft when diff --git a/docs/cli/export.md b/docs/cli/export.md index 789256960..14591b935 100644 --- a/docs/cli/export.md +++ b/docs/cli/export.md @@ -15,20 +15,24 @@ through `1000`. ## TUI reviewed save -Press `e` while an exact selected record has focus in the HUD results or -detail pane. One compact dialog remembers the export directory and filename -template in TUI-private user configuration. On first use, the filename -template is `{date} {time} - {title}.md`; after the preferences are saved -successfully, the remembered directory and template replace the first-use -defaults. Directory completion lists existing child directories and accepts a -choice with the arrow keys and Tab. - -The preview freezes local time when the dialog opens. The date and time render -as the filesystem-safe `YYYY-MM-DD HH-MM-SS`, and the title token uses a -bounded normalized form of the record title without reading its body or source -path. Submitting the draft shows the directory and exact filename separately. -The confirmation starts on **No**; No returns to editing with both values -intact. +Press `e` while an exact selected record has focus in the HUD results or detail +pane, or type `/export [PATH]`. Both routes open the same reviewed flow in the +right detail pane. The command text is transient: the pane restores the current +search term and its exact selection, then returns to the originating focus on +back or save. An optional path seeds the directory and filename fields. + +The pane remembers the export directory and filename template in TUI-private +user configuration. On first use, the filename template is +`{date} {time} - {title}.md`; after the preferences are saved successfully, the +remembered directory and template replace the first-use defaults. Directory +completion lists existing child directories and accepts a choice with the +arrow keys and Tab. + +The preview freezes local time when the pane opens. The date and time render as +the filesystem-safe `YYYY-MM-DD HH-MM-SS`, and the title token uses a bounded +normalized form of the record title without reading its body or source path. +Submitting the draft shows the directory and exact filename separately. The +confirmation starts on **No**; No returns to editing with both values intact. Save is the mutation boundary: No and cancel perform no filesystem mutation. An accepted Save creates the exact app-owned default directory privately when diff --git a/docs/dev/adr/0017-portable-record-export.md b/docs/dev/adr/0017-portable-record-export.md index eaced26bc..675e789c1 100644 --- a/docs/dev/adr/0017-portable-record-export.md +++ b/docs/dev/adr/0017-portable-record-export.md @@ -94,24 +94,28 @@ Defaults express the authority of each caller: The CLI accepts limits from 1 through 1000 and an explicit `-o -` standard output sink. A file refuses overwrite unless the user supplies `--force`. -The HUD commands `/export [PATH]` and `/export-thread [PATH]` default to private -Markdown files with bodies. The latter selects only records in the current -filtered result set whose canonical thread ID matches the selected record. -Identity, rendering, and disk work run off the Textual message pump. Only one -accepted write may be pending, and a changed result snapshot cancels an -observed-thread export instead of writing a mixed view. - -Pressing `e` in a content pane captures the exact selected record and opens one -staged TUI dialog. The dialog remembers its reviewed directory and filename -template in a TUI-private preference file under the platform user -configuration directory. It previews a filename, validates an existing -directory or the uncreated exact app default, then shows the directory and -exact basename separately with **No** selected. No returns to the retained -draft. Save is the mutation boundary: No and cancel perform no filesystem -mutation. An accepted Save securely creates the app default when needed, -writes the explicit no-clobber destination, then attempts to persist the -reviewed preference. CLI and MCP do not consume this preference or gain any -additional filesystem authority. +Pressing `e` in a content pane or submitting `/export [PATH]` captures the exact +selected record and opens one staged flow in the right detail pane. Slash +command text is transient: entering the pane restores the prior search value +and exact selection, and leaving it restores the originating focus. An optional +path seeds the directory and filename fields. + +The pane remembers its reviewed directory and filename template in a +TUI-private preference file under the platform user configuration directory. +It previews a filename, validates an existing directory or the uncreated exact +app default, then shows the directory and exact basename separately with +**No** selected. No returns to the retained draft. Save is the mutation +boundary: No and cancel perform no filesystem mutation. An accepted Save +securely creates the app default when needed, writes the explicit no-clobber +destination, then attempts to persist the reviewed preference. + +The `/export-thread [PATH]` command remains a direct one-shot private Markdown +export with bodies. It selects only records in the current filtered result set +whose canonical thread ID matches the selected record. Identity, rendering, +and disk work run off the Textual message pump. Only one accepted write may be +pending, and a changed result snapshot cancels an observed-thread export +instead of writing a mixed view. CLI and MCP do not consume this preference or +gain any additional filesystem authority. The MCP {tooliconl}`export_records` tool accepts one to 20 unique `agref1:` search refs and no query, cursor, or local destination. It resolves refs with @@ -149,7 +153,8 @@ canonical IDs and structural metadata, never prompt text, a title, or a source path, and collisions allocate a new name rather than replacing an older export. Errors remain path-free. -The reviewed `e` dialog is a narrow exception to that automatic filename +The reviewed selected-record pane is a narrow exception to that automatic +filename policy. Its default template combines a filesystem-safe local timestamp with a bounded normalized `SearchRecord.title`; slugging never reads the record body or source path. The user sees the exact basename before accepting an diff --git a/docs/tui/index.md b/docs/tui/index.md index b4e5125a9..562da8520 100644 --- a/docs/tui/index.md +++ b/docs/tui/index.md @@ -292,27 +292,28 @@ it. If a paste comes back stale, that is where to look first. ## Export -The HUD offers two pi-like, one-shot slash commands: - -- `/export [PATH]` exports exactly the selected record. -- `/export-thread [PATH]` exports the selected record's observed thread from - the current result set after the in-list filter. A record without a canonical - thread handle cannot be exported as a thread. - -Press `e` with the results list or detail pane focused to review the exact -selected record before saving it. The dialog starts from the remembered -explicit directory and filename template, previews the exact filename, and -keeps both values when No returns to editing. Save is the mutation boundary: -No and cancel perform no filesystem mutation. Save securely creates the exact -app default when needed, writes that reviewed new destination, then attempts to -write the TUI-private preference file. The remembered values change only when -that preference write succeeds. The contextual `/keys` panel lists the -shortcut without adding it to the compact footer. - -The slash commands do not read or change those remembered values. Supplying -`PATH` gives that invocation an explicit one-shot destination. - -Without `PATH`, both commands write a collision-free Markdown artifact to +The HUD offers two pi-like export flows: + +- Press `e` with the results list or detail pane focused, or type + `/export [PATH]`, to review exactly the selected record in the right detail + pane. An optional path seeds the directory and filename fields; without one, + the pane starts from the remembered directory and filename template. +- `/export-thread [PATH]` is the one-shot command. It exports the selected + record's observed thread from the current result set after the in-list + filter. A record without a canonical thread handle cannot be exported as a + thread. + +Slash-command text is transient: opening the export pane restores the current +search term and its exact selection, and returning from the pane restores the +originating focus. The pane previews the exact filename and keeps both fields +when No returns to editing. Save is the mutation boundary: No and cancel +perform no filesystem mutation. Save securely creates the exact app default +when needed, writes that reviewed new destination, then attempts to write the +TUI-private preference file. The remembered values change only when that +preference write succeeds. The contextual `/keys` panel lists the `e` shortcut +without adding it to the compact footer. + +Without `PATH`, `/export-thread` writes a collision-free Markdown artifact to agentgrep's private export directory. Its root follows `XDG_DATA_HOME`; when set, artifacts go under `$XDG_DATA_HOME/agentgrep/exports`, and otherwise the standard XDG data location is used. The directory uses mode `0700`, and each @@ -330,9 +331,9 @@ view changes while the HUD is taking the snapshot. Export does not replace the loaded results or change the detail selection. Source stores remain read-only. A successful reviewed Save may write both the -new artifact and its TUI-private preference file; one-shot slash commands write -only their artifact. See {ref}`ADR 0017 ` for the -payload, fidelity, and file-safety contract. +new artifact and its TUI-private preference file; the one-shot thread command +writes only its artifact. See {ref}`ADR 0017 ` for +the payload, fidelity, and file-safety contract. ## Completion diff --git a/src/agentgrep/ui/commands.py b/src/agentgrep/ui/commands.py index f229c59f8..61c800167 100644 --- a/src/agentgrep/ui/commands.py +++ b/src/agentgrep/ui/commands.py @@ -78,6 +78,7 @@ def _run_clear(app: t.Any, args: str) -> bool: del args app.control.request_answer_now() app.reset_view() + app.clear_search_draft() return True @@ -155,8 +156,8 @@ def _run_minimize(app: t.Any, args: str) -> bool: def _run_export(app: t.Any, args: str) -> bool: - """Export the selected record to a private or explicit destination.""" - return bool(app.request_export(args, selection="records")) + """Review the selected record in the detail-pane export flow.""" + return bool(app.open_export_pane(args)) def _run_export_thread(app: t.Any, args: str) -> bool: diff --git a/src/agentgrep/ui/layouts/_base.py b/src/agentgrep/ui/layouts/_base.py index 6d37d691a..fd7424a87 100644 --- a/src/agentgrep/ui/layouts/_base.py +++ b/src/agentgrep/ui/layouts/_base.py @@ -289,13 +289,17 @@ def _dispatch_slash_text(self, text: str) -> bool | None: return succeeded def _clear_command_input(self) -> None: - """Clear and refocus the shared search input after command success.""" + """Restore and refocus the query draft after a transient command.""" search_input = getattr(self, "_search_input", None) if search_input is None: return - search_input.value = "" - search_input.cursor_position = 0 - search_input.focus() + search_input.restore_query_draft() + + def clear_search_draft(self) -> None: + """Make an intentionally cleared query survive command cleanup.""" + search_input = getattr(self, "_search_input", None) + if search_input is not None: + search_input.clear_query_draft() def _hide_command_completion(self) -> None: """Hide the shared slash-command dropdown after execution.""" diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index 1205098ee..1c7a3e935 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -24,6 +24,7 @@ from textual.css.query import NoMatches from textual.timer import Timer from textual.widgets import Footer, Static +from textual.widgets.input import Selection from agentgrep._query_gate import strip_depth_directive from agentgrep._types import ( @@ -57,10 +58,11 @@ DetailFindInput, DetailFocusRequested, DetailScroll, - ExportDialog, + ExportPane, FilterHeader, FilterInput, PaneHeader, + ResultHighlighted, ResultsHeader, SearchingPanel, SearchInput, @@ -69,7 +71,6 @@ WelcomeExamples, WelcomeQuerySelected, ) -from agentgrep.ui.widgets.export_dialog import ExportIntent from agentgrep.ui.widgets.welcome import ( _WELCOME_BRAND_SHINE, _WELCOME_QUERIES, @@ -113,6 +114,17 @@ class _ExportCompleted: error: str | None +@dataclasses.dataclass(frozen=True, slots=True) +class _ExportPaneReturn: + """Exact input and focus state restored after a transient export pane.""" + + focused: object | None + search_value: str + search_selection: Selection + zoomed_pane: t.Literal["results", "detail"] | None + detail_opened: bool + + class _ExportSnapshotChangedError(Exception): """Stop a chunked snapshot when its displayed result set changes.""" @@ -253,7 +265,8 @@ def __init__(self, ctx: UiContext, workflow: Workflow) -> None: self._export_pending: bool = False self._export_generation: int = 0 self._export_cancel_event: threading.Event | None = None - self._export_dialog: ExportDialog | None = None + self._export_pane: ExportPane | None = None + self._export_pane_return: _ExportPaneReturn | None = None self._results: SearchResultsList | None = None # The detail pane is un-Grouped into two stacked, individually # selectable ``Static``s: the metadata header and the body. A single @@ -634,7 +647,8 @@ def on_unmount(self) -> None: """Invalidate export callbacks and cancel work during screen teardown.""" self._export_generation += 1 self._export_pending = False - self._export_dialog = None + self._export_pane = None + self._export_pane_return = None if self._export_cancel_event is not None: self._export_cancel_event.set() self._export_cancel_event = None @@ -934,6 +948,9 @@ def _after_resize(self) -> None: def action_stop_search(self) -> None: """``Esc``: cooperative early-exit of the worker (no-op when finished).""" + if self._export_pane is not None: + self._export_pane.action_escape() + return self._cancel_active_action() @_runtime.pump_only @@ -946,6 +963,9 @@ def action_smart_quit(self) -> None: press cancels it; otherwise it arms the same "press ctrl-c again to exit" gutter as the inputs, so the warning shows whichever pane holds focus. """ + if self._export_pane is not None: + self._export_pane.action_cancel() + return if self._has_active_actions(): self._disarm_confirm_exit() self._cancel_active_action() @@ -1046,11 +1066,17 @@ def _focus_detail(self) -> None: def action_focus_pane_left(self) -> None: """``Ctrl-H``: leave the detail pane back to the results.""" + if self._export_pane is not None: + self._export_pane.action_editor_previous() + return if self.focused is not None and self.focused.id == "detail-scroll": self._focus_widget_by_id("results") def action_focus_pane_right(self) -> None: """``Ctrl-L``: focus the detail pane (to the right / opened below).""" + if self._export_pane is not None: + self._export_pane.action_editor_next() + return if self.focused is not None and self.focused.id in ( "results", "filter", @@ -1066,6 +1092,9 @@ def action_focus_pane_up(self) -> None: top-level search bar. When stacked, the detail sits below the results, so ``up`` from the detail lands on the results. """ + if self._export_pane is not None: + self._export_pane.action_editor_previous() + return focused_id = self.focused.id if self.focused is not None else None if focused_id == "detail-scroll": self._focus_widget_by_id("results" if self._stacked else "filter") @@ -1080,6 +1109,9 @@ def action_focus_pane_down(self) -> None: When stacked, ``down`` from the results reaches the detail pane below them (opening it if needed). """ + if self._export_pane is not None: + self._export_pane.action_editor_next() + return focused_id = self.focused.id if self.focused is not None else None if focused_id == "search": self._focus_widget_by_id("filter") @@ -1090,54 +1122,168 @@ def action_focus_pane_down(self) -> None: @_runtime.pump_only def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: - """Expose record export only in a content pane with a live selection.""" + """Keep global bindings from escaping an active export action.""" + if self._export_pane is not None and action in { + "confirm_quit", + "recall_history", + }: + return False if action == "export_selected": - return self._selected_export_shortcut_record() is not None + return self._export_pane is None and self._selected_export_shortcut_record() is not None return super().check_action(action, parameters) @_runtime.pump_only - def action_export_selected(self) -> None: - """Review one exact content-pane selection before exporting it.""" - if self._export_dialog is not None: + def action_recall_history(self) -> None: + """Open history only when no transient export pane owns input.""" + if self._export_pane is not None: + return + super().action_recall_history() + + @_runtime.pump_only + def on_result_highlighted(self, message: ResultHighlighted) -> None: + """Freeze the retained reader while an export pane owns it.""" + if self._export_pane is not None: return + super().on_result_highlighted(message) + + @_runtime.pump_only + def action_export_selected(self) -> None: + """Open the detail-pane export flow for one exact selection.""" selected = self._selected_export_shortcut_record() if selected is None: return - dialog = ExportDialog( - title=selected.title or "", - fallback_title=f"{selected.agent}-{selected.kind}", - home=self.home, - preferences=self._export_preferences, - on_confirm=functools.partial(self._confirm_export_dialog, selected), - ) - self._export_dialog = dialog - self.app.push_screen( - dialog, - functools.partial(self._clear_export_dialog, dialog), - ) + self.open_export_pane("", selected_record=selected) @_runtime.pump_only - def _confirm_export_dialog( + def open_export_pane( self, - selected: SearchRecord, - intent: ExportIntent, + destination: str, + *, + selected_record: SearchRecord | None = None, ) -> bool: - """Start the durable worker for one retained reviewed intent.""" - dialog = self._export_dialog - if dialog is None or not dialog.is_mounted: + """Mount one fresh export owner beside the retained detail reader.""" + if self._export_pane is not None: return False - return self.request_export( - str(intent.destination), - selection="records", + selected = selected_record or self._selected_export_record() + if ( + selected is None + or self._detail_column is None + or self._body is None + or self._search_input is None + ): + self.notify( + "Select a record before exporting", + title="Export failed", + severity="error", + ) + return False + + search = self._search_input + search_value = str(search.value) + search_selection = search.selection + if search_value.lstrip().startswith("/"): + search_value, search_selection = search.query_draft + + preferences = self._export_preferences + if destination: + explicit = pathlib.Path(destination) + preferences = ExportPreferences( + directory=str(explicit.parent), + filename_template=explicit.name, + ) + + pane = ExportPane( selected_record=selected, - preferences=intent.preferences, + home=self.home, + preferences=preferences, + ) + self._export_pane = pane + self._export_pane_return = _ExportPaneReturn( + focused=self.focused, + search_value=search_value, + search_selection=search_selection, + zoomed_pane=self._zoomed_pane, + detail_opened=self._detail_opened, + ) + t.cast("t.Any", self._body).add_class("-export-pane") + t.cast("t.Any", self._detail_column).add_class("-exporting") + t.cast("t.Any", self._detail_column).mount(pane) + return True + + @_runtime.pump_only + def on_export_pane_confirmed(self, message: ExportPane.Confirmed) -> None: + """Start the durable writer for this pane's frozen reviewed record.""" + pane = self._export_pane + if pane is not message.pane or not pane.is_mounted: + return + accepted = self.request_export( + str(message.intent.destination), + selection="records", + selected_record=pane.selected_record, + preferences=message.intent.preferences, ) + if not accepted: + pane.export_failed("Export could not be started") + + @_runtime.pump_only + async def on_export_pane_close_requested( + self, + message: ExportPane.CloseRequested, + ) -> None: + """Remove one exact pane, then restore its query and originating focus.""" + pane = self._export_pane + state = self._export_pane_return + if pane is not message.pane or state is None: + return + pane.display = False + await pane.remove() + if self._export_pane is not pane: + return + self._export_pane = None + self._export_pane_return = None + if self._body is not None: + t.cast("t.Any", self._body).remove_class("-export-pane") + if self._detail_column is not None: + t.cast("t.Any", self._detail_column).remove_class("-exporting") + self._restore_export_search(state, focus=False) + target = state.focused + if ( + target is not None + and getattr(target, "is_mounted", False) + and getattr(target, "display", False) + and not getattr(target, "disabled", False) + and getattr(target, "can_focus", False) + ): + t.cast("t.Any", target).focus() + elif self._search_input is not None: + self._search_input.focus() + self._detail_opened = state.detail_opened + if state.zoomed_pane is None: + self.handle_minimize_command() + else: + self._set_zoomed_pane(state.zoomed_pane) + self._apply_responsive_layout() + self._update_pane_focus() + + @_runtime.pump_only + def _restore_export_search(self, state: _ExportPaneReturn, *, focus: bool) -> None: + """Restore the exact query draft without synthesizing key presses.""" + if self._search_input is None: + return + self._search_input.value = state.search_value + self._search_input.selection = state.search_selection + if focus: + self._search_input.focus() @_runtime.pump_only - def _clear_export_dialog(self, dialog: ExportDialog, _result: None) -> None: - """Forget only the retained dialog whose dismissal just completed.""" - if self._export_dialog is dialog: - self._export_dialog = None + def _clear_command_input(self) -> None: + """Restore a query without stealing focus from a new export pane.""" + state = self._export_pane_return + if self._export_pane is not None and state is not None: + self._restore_export_search(state, focus=False) + self._hide_command_completion() + return + super()._clear_command_input() @_runtime.pump_only def request_export( @@ -1482,9 +1628,9 @@ def _apply_export_completed(self, generation: int, event: object) -> None: if not self.is_mounted: return if event.error is not None: - dialog = self._export_dialog - if dialog is not None and dialog.is_mounted and dialog.phase == "saving": - dialog.export_failed(event.error) + pane = self._export_pane + if pane is not None and pane.is_mounted and pane.phase == "saving": + pane.export_failed(event.error) return self.notify( event.error, @@ -1494,11 +1640,9 @@ def _apply_export_completed(self, generation: int, event: object) -> None: return if event.preferences is not None: self._export_preferences = event.preferences - dialog = self._export_dialog - if dialog is not None and dialog.phase == "saving": - dialog.export_succeeded() - if self._export_dialog is dialog: - self._export_dialog = None + pane = self._export_pane + if pane is not None and pane.phase == "saving": + pane.export_succeeded() noun = "record" if event.record_count == 1 else "records" self.notify( f"{event.filename} · {event.format} · {event.selection} · {event.record_count} {noun}", diff --git a/src/agentgrep/ui/styles.tcss b/src/agentgrep/ui/styles.tcss index f1afab6f7..11a8892f4 100644 --- a/src/agentgrep/ui/styles.tcss +++ b/src/agentgrep/ui/styles.tcss @@ -433,6 +433,23 @@ Screen { #body.-zoom-detail > #results-column { display: none; } +/* A transient detail action owns the body without rewriting the user's + responsive, collapsed, or logical zoom state. Removing this one class + reveals the exact reader layout that was already underneath it. */ +#body.-export-pane > #results-column { + display: none; +} +#body.-export-pane > #detail-column { + display: block; + width: 1fr; + height: 1fr; +} +#detail-column.-exporting > #detail-header, +#detail-column.-exporting > #detail-scroll, +#detail-column.-exporting > #detail-find, +#detail-column.-exporting > #detail-statusline { + display: none; +} #filter { height: 1; /* Dedicated filter/results rules own both separators, leaving this input @@ -677,12 +694,17 @@ HistoryRecall, HistoryRecall:ansi { color: $ag-dim; } -/* Selected-record export: one quiet edit/review flow. The exact basename is +/* Selected-record export: one quiet detail-pane flow. The exact basename is the only signature treatment; labels and state copy stay subordinate. */ -ExportDialog { +ExportPane { background: transparent; } -#export-dialog { +#export-pane-header { + height: 1; + width: 1fr; + color: $accent; +} +#export-flow { background: ansi_default; } .export-label { diff --git a/src/agentgrep/ui/widgets/__init__.py b/src/agentgrep/ui/widgets/__init__.py index ac36b2065..62e4efd42 100644 --- a/src/agentgrep/ui/widgets/__init__.py +++ b/src/agentgrep/ui/widgets/__init__.py @@ -14,7 +14,7 @@ from agentgrep.ui.widgets.detail import DetailScroll from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker from agentgrep.ui.widgets.dropdown import CompletionDropdown -from agentgrep.ui.widgets.export_dialog import ExportDialog +from agentgrep.ui.widgets.export_pane import ExportPane from agentgrep.ui.widgets.history import HistoryRecall from agentgrep.ui.widgets.inputs import DetailFindInput, FilterInput, SearchInput from agentgrep.ui.widgets.messages import ( @@ -59,8 +59,8 @@ "DetailFocusRequested", "DetailScroll", "DetailScrollChanged", - "ExportDialog", "ExportDirectoryPicker", + "ExportPane", "FilterCompleted", "FilterHeader", "FilterInput", diff --git a/src/agentgrep/ui/widgets/export_dialog.py b/src/agentgrep/ui/widgets/export_pane.py similarity index 89% rename from src/agentgrep/ui/widgets/export_dialog.py rename to src/agentgrep/ui/widgets/export_pane.py index c239be729..551f04e53 100644 --- a/src/agentgrep/ui/widgets/export_dialog.py +++ b/src/agentgrep/ui/widgets/export_pane.py @@ -1,4 +1,4 @@ -"""Staged, no-clobber export confirmation for one selected record.""" +"""Staged, no-clobber export flow for the active detail pane.""" from __future__ import annotations @@ -15,10 +15,11 @@ from textual.binding import Binding from textual.containers import Vertical, VerticalScroll from textual.content import Content -from textual.screen import ModalScreen +from textual.message import Message from textual.widgets import Input, OptionList, Static from textual.worker import NoActiveWorker, get_current_worker +from agentgrep.records import SearchRecord from agentgrep.ui import _runtime from agentgrep.ui._export_preferences import ( MAX_DIRECTORY_CHARS, @@ -30,10 +31,11 @@ resolve_export_directory, ) from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker +from agentgrep.ui.widgets.status import PaneHeader -__all__ = ["ExportDialog", "ExportDraft", "ExportIntent"] +__all__ = ["ExportDraft", "ExportIntent", "ExportPane"] -_VALIDATION_WORKER_GROUP = "export-dialog-validation" +_VALIDATION_WORKER_GROUP = "export-pane-validation" _DIRECTORY_ERROR = "Export directory is invalid" _DIRECTORY_UNAVAILABLE_ERROR = "Export directory is unavailable" _DIRECTORY_ACCESS_ERROR = "Export directory is not writable" @@ -139,8 +141,23 @@ def _validate_export_draft( ) -class ExportDialog(ModalScreen[None]): - """Edit, validate, review, and retain one selected-record export.""" +class ExportPane(Vertical): + """Edit, validate, and review one frozen selected-record export.""" + + class CloseRequested(Message): + """Ask the HUD owner to remove this exact pane.""" + + def __init__(self, pane: ExportPane) -> None: + super().__init__() + self.pane = pane + + class Confirmed(Message): + """Carry one reviewed intent to the HUD's durable writer boundary.""" + + def __init__(self, pane: ExportPane, intent: ExportIntent) -> None: + super().__init__() + self.pane = pane + self.intent = intent BINDINGS: t.ClassVar[list[Binding]] = [ Binding("escape", "escape", "Back / Cancel", priority=True, show=False), @@ -156,15 +173,14 @@ class ExportDialog(ModalScreen[None]): ] DEFAULT_CSS = """ - ExportDialog { - align: center middle; + ExportPane { + width: 100%; + height: 1fr; } - #export-dialog { + #export-flow { width: 100%; - max-width: 72; - height: 100%; - max-height: 18; - padding: 0 2; + height: 1fr; + padding: 0 1; } #export-edit, #export-review { width: 100%; @@ -201,11 +217,6 @@ class ExportDialog(ModalScreen[None]): #export-review { display: none; } - #export-dialog.-reviewing, - #export-dialog.-reviewing #export-review { - height: auto; - max-height: 12; - } #export-confirm { width: 12; height: 2; @@ -214,18 +225,16 @@ class ExportDialog(ModalScreen[None]): def __init__( self, - title: str, - fallback_title: str, + selected_record: SearchRecord, home: pathlib.Path, preferences: ExportPreferences, - on_confirm: cabc.Callable[[ExportIntent], bool], timestamp: datetime.datetime | None = None, ) -> None: - super().__init__() - self._title = title - self._fallback_title = fallback_title + super().__init__(id="export-pane") + self._selected_record = selected_record + self._title = selected_record.title or "" + self._fallback_title = f"{selected_record.agent}-{selected_record.kind}" self._home = home - self._on_confirm = on_confirm self._timestamp = timestamp or datetime.datetime.now().astimezone() self._initial_preferences = dataclasses.replace( preferences, @@ -240,13 +249,19 @@ def __init__( @property def phase(self) -> ExportPhase: - """Return the dialog's current interaction phase.""" + """Return the pane's current interaction phase.""" return self._phase + @property + def selected_record(self) -> SearchRecord: + """Return the exact record frozen when this pane was created.""" + return self._selected_record + @_runtime.pump_only def compose(self) -> ComposeResult: """Compose one quiet edit/review flow with literal output surfaces.""" - with Vertical(id="export-dialog"): + yield PaneHeader("export", id="export-pane-header") + with Vertical(id="export-flow"): with VerticalScroll(id="export-edit"): yield Static("Directory", classes="export-label") yield ExportDirectoryPicker( @@ -334,7 +349,7 @@ def action_escape(self) -> None: if self._phase == "review": self._show_edit() return - self._dismiss_dialog() + self._request_close() @_runtime.pump_only def action_cancel(self) -> None: @@ -352,7 +367,7 @@ def action_cancel(self) -> None: editor.value = "" editor.focus() return - self._dismiss_dialog() + self._request_close() @_runtime.pump_only def action_editor_previous(self) -> None: @@ -397,13 +412,13 @@ def export_failed(self, message: str) -> None: def export_succeeded(self) -> None: """Dismiss after the asynchronous writer reports success.""" if self.is_mounted and self._phase == "saving": - self._dismiss_dialog() + self._request_close() @_runtime.pump_only - def _dismiss_dialog(self) -> None: - """Invalidate deferred feedback before dismissing the modal.""" + def _request_close(self) -> None: + """Invalidate deferred feedback and ask the HUD to restore its reader.""" self._invalidate_error_reveal() - self.dismiss(None) + self.post_message(self.CloseRequested(self)) @_runtime.pump_only def _refresh_preview(self) -> bool: @@ -527,7 +542,6 @@ def _show_edit(self, error: str | None = None) -> None: """Restore the retained edit stage and its prior focus.""" self._phase = "edit" self._intent = None - self.query_one("#export-dialog", Vertical).remove_class("-reviewing") edit = self.query_one("#export-edit", VerticalScroll) review = self.query_one("#export-review", VerticalScroll) edit.display = True @@ -558,7 +572,6 @@ def _reveal_error(self, generation: int, message: str) -> None: not message or self._pending_error_reveal != request or not self.is_mounted - or self.app.screen is not self or self._phase != "edit" ): return @@ -573,7 +586,6 @@ def _show_review(self, intent: ExportIntent) -> None: """Show the literal directory and exact basename with No selected.""" self._invalidate_error_reveal() self._phase = "review" - self.query_one("#export-dialog", Vertical).add_class("-reviewing") self.query_one("#export-edit", VerticalScroll).display = False self.query_one("#export-review", VerticalScroll).display = True self.query_one("#export-review-directory", Static).update( @@ -589,6 +601,17 @@ def _show_review(self, intent: ExportIntent) -> None: confirm.highlighted = 0 self._update_review_choices(0) confirm.focus() + self.call_after_refresh(self._reveal_review_choices) + + @_runtime.pump_only + def _reveal_review_choices(self) -> None: + """Keep the No-first choice visible after compact review reflow.""" + if not self.is_mounted or self._phase != "review": + return + self.query_one("#export-confirm", OptionList).scroll_visible( + animate=False, + immediate=True, + ) @_runtime.pump_only def _update_review_choices(self, highlighted: int) -> None: @@ -601,14 +624,13 @@ def _update_review_choices(self, highlighted: int) -> None: @_runtime.pump_only def _confirm(self) -> None: - """Delegate once and retain the modal while the writer is active.""" + """Post once and retain the pane while the writer is active.""" intent = self._intent if self._phase != "review" or intent is None: return - if not self._on_confirm(intent): - return self._phase = "saving" confirm = self.query_one("#export-confirm", OptionList) confirm.highlighted = 1 confirm.disabled = True self.query_one("#export-review-status", Static).update(Content("Saving…")) + self.post_message(self.Confirmed(self, intent)) diff --git a/src/agentgrep/ui/widgets/inputs.py b/src/agentgrep/ui/widgets/inputs.py index 19b644ee9..e6a8dadf5 100644 --- a/src/agentgrep/ui/widgets/inputs.py +++ b/src/agentgrep/ui/widgets/inputs.py @@ -21,6 +21,7 @@ from textual.suggester import Suggester from textual.timer import Timer from textual.widgets import Input +from textual.widgets.input import Selection from agentgrep.progress import FilterRequestedPayload, SearchRequestedPayload from agentgrep.ui import _runtime @@ -380,6 +381,8 @@ def __init__( highlighter: Highlighter | None = None, label: str | None = None, ) -> None: + self._query_draft_value = value + self._query_draft_selection = Selection.cursor(len(value)) super().__init__( value=value[:INPUT_MAX_LENGTH], placeholder=placeholder, @@ -400,6 +403,40 @@ def load_query(self, value: str) -> None: animate=False, ) + @property + def query_draft(self) -> tuple[str, Selection]: + """Return the last non-command value and its exact selection.""" + return self._query_draft_value, self._query_draft_selection + + def clear_query_draft(self) -> None: + """Make the empty query the value restored after a transient command.""" + self._query_draft_value = "" + self._query_draft_selection = Selection.cursor(0) + + def restore_query_draft(self, *, focus: bool = True) -> None: + """Restore the last non-command value without synthesizing keystrokes.""" + value, selection = self.query_draft + self.value = value + self.selection = selection + if focus: + self.focus() + + @_runtime.pump_only + def _watch_value(self, value: str) -> None: + """Remember ordinary query edits while slash invocations stay transient.""" + super()._watch_value(value) + if not value.lstrip().startswith("/"): + self._query_draft_value = value + self._query_draft_selection = self.selection + + @_runtime.pump_only + def _watch_selection(self, selection: Selection) -> None: + """Retain the exact query cursor or selection independently of edits.""" + super()._watch_selection(selection) + if not self.value.lstrip().startswith("/"): + self._query_draft_value = self.value + self._query_draft_selection = selection + def _sync_submit_hint(self, value: str) -> None: """Show the submit affordance only while a nonblank query is ready.""" self.border_subtitle = _SUBMIT_HINT if value.strip() else None diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 3d7ff9be9..5306c1a97 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -85,7 +85,7 @@ def test_export_cli_docs_define_defaults_and_safe_sinks() -> None: def test_export_tui_docs_define_private_off_pump_workflow() -> None: - """The TUI guide covers both pi-like commands and safe notifications.""" + """The TUI guide separates reviewed record export from direct thread export.""" tui = _read_text("docs/tui/index.md") section = _markdown_section(tui, "## Export") normalized = re.sub(r"\s+", " ", section).casefold() @@ -93,6 +93,8 @@ def test_export_tui_docs_define_private_off_pump_workflow() -> None: "`/export [PATH]`", "`/export-thread [PATH]`", "Press `e`", + "right detail pane", + "restores the current search term", "selected record", "observed thread", "current result set", @@ -111,11 +113,15 @@ def test_export_tui_docs_define_private_off_pump_workflow() -> None: missing = _missing_terms(section, required) assert not missing, f"docs/tui/index.md is missing {missing!r}" assert re.search( - r"press `e`.*review.*remembered.*directory and filename template.*exact filename", + r"press `e`.*`/export \[path\]`.*right detail pane.*remembered" + r".*directory and filename template.*exact filename", + normalized, + ) + assert re.search(r"`/export-thread \[path\]`.*one-shot", normalized) + assert re.search( + r"without `path`, `/export-thread`.*private export directory", normalized, ) - assert re.search(r"slash commands.*one-shot", normalized) - assert re.search(r"without `path`, both commands.*private export directory", normalized) assert re.search(r"`/export-thread \[path\]`.*observed thread", normalized) @@ -127,15 +133,18 @@ def test_export_guide_defines_reviewed_tui_destination() -> None: for literal in ( "`e`", + "`/export [PATH]`", "`{date} {time} - {title}.md`", "`YYYY-MM-DD HH-MM-SS`", "no-clobber", ): assert literal in section assert re.search( - r"exact selected record.*remembers the export directory and filename template", + r"exact selected record.*right detail pane" + r".*remembers the export directory and filename template", normalized, ) + assert re.search(r"restores the current search term.*exact selection", normalized) assert re.search( r"first use.*after the preferences are saved successfully" r".*remembered directory and template", @@ -363,6 +372,8 @@ def test_export_adr_pins_interactive_filename_exception() -> None: durable = re.sub(r"\s+", " ", durable_section).casefold() assert "`e`" in surface + assert "`/export [path]`" in surface + assert re.search(r"right detail pane.*restores.*search", surface) assert re.search(r"exact selected record.*remembers.*directory and filename template", surface) assert re.search(r"exact basename.*\bno returns.*explicit no-clobber destination", surface) assert re.search(r"cli and mcp do not consume this preference", surface) diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 9d8b71a86..24e0f11e2 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -25,7 +25,7 @@ save_export_preferences, ) from agentgrep.ui.layouts import hud as hud_module -from agentgrep.ui.widgets import ExportDialog, FilterCompleted, SearchRequested +from agentgrep.ui.widgets import ExportPane, FilterCompleted, SearchRequested from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker from tests.test_agentgrep_tui_identity import _build_empty_ui_app @@ -91,9 +91,9 @@ async def _wait_for(predicate: t.Callable[[], bool], *, timeout: float = 3.0) -> pytest.fail("timed out waiting for export worker") -def _static_text(dialog: ExportDialog, selector: str) -> str: - """Return the literal plain text last assigned to a dialog ``Static``.""" - static = dialog.query_one(selector, Static) +def _static_text(pane: ExportPane, selector: str) -> str: + """Return the literal plain text last assigned to a pane ``Static``.""" + static = pane.query_one(selector, Static) content = getattr(static, "_Static__content", "") return getattr(content, "plain", str(content)) @@ -104,19 +104,18 @@ async def _open_export_review( *, directory: pathlib.Path, template: str, -) -> tuple[ExportDialog, str]: - """Open the selected-record dialog and advance one draft to review.""" +) -> tuple[ExportPane, str]: + """Open the selected-record pane and advance one draft to review.""" await pilot.press("e") await pilot.pause() - assert isinstance(app.screen, ExportDialog) - dialog = app.screen - dialog.query_one("#export-directory", ExportDirectoryPicker).value = str(directory) - template_input = dialog.query_one("#export-template", Input) + pane = app.screen.query_one(ExportPane) + pane.query_one("#export-directory", ExportDirectoryPicker).value = str(directory) + template_input = pane.query_one("#export-template", Input) template_input.value = template template_input.focus() await pilot.press("enter") - await _wait_for(lambda: dialog.phase == "review") - return dialog, _static_text(dialog, "#export-review-filename") + await _wait_for(lambda: pane.phase == "review") + return pane, _static_text(pane, "#export-review-filename") def _capture_notifications( @@ -207,9 +206,8 @@ async def test_export_shortcut_confirms_selected_record_and_appears_in_keys( await pilot.press("e") await pilot.pause() - assert isinstance(app.screen, ExportDialog) - dialog = app.screen - assert hud._export_dialog is dialog + dialog = hud.query_one(ExportPane) + assert hud._export_pane is dialog assert list(export_dir.glob("*.md")) == [] hud._results.highlighted = 1 hud._current_detail_record = records[0] @@ -339,7 +337,7 @@ def tracked_save(home: pathlib.Path, preferences: ExportPreferences) -> None: await pilot.press("y") await _wait_for(destination.exists) - await _wait_for(lambda: app.screen is hud) + await _wait_for(lambda: not hud.query(ExportPane)) assert order == ["artifact", "preferences"] assert write_calls == [ @@ -353,7 +351,7 @@ def tracked_save(home: pathlib.Path, preferences: ExportPreferences) -> None: directory=str(export_dir), filename_template="reviewed-{title}.md", ) - assert hud._export_dialog is None + assert hud._export_pane is None assert len(notes) == 1 assert filename in str(notes[0][0][0]) @@ -387,7 +385,7 @@ async def test_absolute_home_preference_persists_as_tilde( await pilot.press("e") await pilot.pause() - dialog = t.cast("ExportDialog", app.screen) + dialog = hud.query_one(ExportPane) picker = dialog.query_one("#export-directory", ExportDirectoryPicker) assert picker.value == "~/Exports" await pilot.press("enter", "enter") @@ -395,7 +393,7 @@ async def test_absolute_home_preference_persists_as_tilde( assert _static_text(dialog, "#export-review-directory") == "~/Exports" await pilot.press("y") - await _wait_for(lambda: app.screen is hud) + await _wait_for(lambda: not hud.query(ExportPane)) assert (export_dir / "private-draft.md").is_file() assert load_export_preferences(home).preferences == ExportPreferences( @@ -451,7 +449,7 @@ def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: selected_dir, ) assert dialog.query_one("#export-template", Input).value == "retry-{title}.md" - assert hud._export_dialog is dialog + assert hud._export_pane is dialog assert not (selected_dir / filename).exists() assert load_export_preferences(tmp_path / "home").preferences == original assert _static_text(dialog, "#export-error") == "export could not be written" @@ -499,8 +497,8 @@ def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: assert await asyncio.to_thread(started.wait, 2) await pilot.press(key) await pilot.pause() - active_during_save = app.screen is dialog - retained_during_save = hud._export_dialog is dialog + active_during_save = dialog.is_mounted + retained_during_save = hud._export_pane is dialog phase_during_save = dialog.phase release.set() await _wait_for(lambda: dialog.phase == "edit") @@ -508,8 +506,8 @@ def fail_write(*_args: object, **_kwargs: object) -> t.NoReturn: assert active_during_save assert retained_during_save assert phase_during_save == "saving" - assert app.screen is dialog - assert hud._export_dialog is dialog + assert dialog.is_mounted + assert hud._export_pane is dialog assert dialog.phase == "edit" assert dialog.query_one("#export-directory", ExportDirectoryPicker).value == directory assert dialog.query_one("#export-template", Input).value == template @@ -552,13 +550,13 @@ def fail_save(_home: pathlib.Path, _preferences: ExportPreferences) -> t.NoRetur await pilot.press("y") await _wait_for(destination.exists) - await _wait_for(lambda: app.screen is hud) + await _wait_for(lambda: not hud.query(ExportPane)) assert destination.read_text(encoding="utf-8").startswith( "# agentgrep record export", ) assert not export_preferences_path(tmp_path / "home").exists() - assert hud._export_dialog is None + assert hud._export_pane is None assert len(notes) == 2 assert any(note[1].get("title") == "Export complete" for note in notes) warning = next(note for note in notes if note[1].get("severity") == "warning") @@ -620,11 +618,11 @@ def slow_write( @pytest.mark.slow -async def test_unmount_invalidates_and_clears_retained_export_dialog( +async def test_unmount_invalidates_and_clears_retained_export_pane( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """HUD teardown drops the modal reference and invalidates completions.""" + """HUD teardown drops the pane reference and invalidates completions.""" monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) app = _build_empty_ui_app(tmp_path, monkeypatch) record = _record(tmp_path, "body", ordinal=1) @@ -635,13 +633,13 @@ async def test_unmount_invalidates_and_clears_retained_export_dialog( hud._results.focus() await pilot.press("e") await pilot.pause() - assert isinstance(app.screen, ExportDialog) - assert hud._export_dialog is app.screen + pane = hud.query_one(ExportPane) + assert hud._export_pane is pane generation = hud._export_generation hud.on_unmount() - assert hud._export_dialog is None + assert hud._export_pane is None assert hud._export_generation == generation + 1 assert hud._export_pending is False @@ -754,12 +752,18 @@ async def test_export_commands_accept_paths_but_legacy_args_stay_searches( app = _build_empty_ui_app(tmp_path, monkeypatch) async with app.run_test(size=(120, 30)) as pilot: await pilot.pause() - requests: list[tuple[str, str]] = [] + pane_requests: list[str] = [] + worker_requests: list[tuple[str, str]] = [] searches: list[object] = [] + monkeypatch.setattr( + app.screen, + "open_export_pane", + lambda path: pane_requests.append(path) or True, + ) monkeypatch.setattr( app.screen, "request_export", - lambda path, *, selection: requests.append((selection, path)), + lambda path, *, selection: worker_requests.append((selection, path)), ) monkeypatch.setattr(app.screen, "_start_search_worker", searches.append) @@ -772,10 +776,8 @@ async def test_export_commands_accept_paths_but_legacy_args_stay_searches( app.screen.on_search_requested(_search_requested("/help still a query")) await pilot.pause() - assert requests == [ - ("records", "nested/result.md"), - ("thread", "thread.md"), - ] + assert pane_requests == ["nested/result.md"] + assert worker_requests == [("thread", "thread.md")] assert len(searches) == 1 @@ -813,7 +815,7 @@ async def test_record_export_writes_markdown_and_preserves_results( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Default and explicit sinks export exactly the selected record.""" + """Bare and path-seeded panes export exactly the selected record.""" monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) saved_preferences: list[ExportPreferences] = [] @@ -840,6 +842,11 @@ async def test_record_export_writes_markdown_and_preserves_results( command = f"/export {destination}" if explicit else "/export" app.screen.on_search_requested(_search_requested(command)) + await pilot.pause() + pane = app.screen.query_one(ExportPane) + await pilot.press("enter", "enter") + await _wait_for(lambda: pane.phase == "review") + await pilot.press("y") if explicit: await _wait_for(destination.exists) exported = destination @@ -862,7 +869,7 @@ async def test_record_export_writes_markdown_and_preserves_results( assert str(exported.parent) not in message assert "markdown" in message assert "1 record" in message - assert saved_preferences == [] + assert len(saved_preferences) == 1 assert not export_preferences_path(tmp_path / "home").exists() @@ -997,7 +1004,7 @@ async def test_record_export_survives_result_change_before_deferred_start( notes = _capture_notifications(app.screen, monkeypatch) scheduled = _defer_export_start(app.screen, monkeypatch) - app.screen.on_search_requested(_search_requested(f"/export {destination}")) + app.screen.request_export(str(destination), selection="records") assert len(scheduled) == 1 _change_results(app.screen, change, replacement) callback, args = scheduled.pop() @@ -1089,7 +1096,7 @@ async def test_explicit_export_refuses_unsafe_destinations( await _load_records(app.screen, (record,)) notes = _capture_notifications(app.screen, monkeypatch) - app.screen.on_search_requested(_search_requested(f"/export {destination}")) + app.screen.request_export(str(destination), selection="records") await _wait_for(lambda: bool(notes)) assert notes[0][1]["severity"] == "error" @@ -1123,7 +1130,7 @@ def fail_write(*args: object, **kwargs: object) -> pathlib.Path: await _load_records(app.screen, (record,)) notes = _capture_notifications(app.screen, monkeypatch) - app.screen.on_search_requested(_search_requested(f"/export {secret_path}")) + app.screen.request_export(str(secret_path), selection="records") await _wait_for(lambda: bool(notes)) assert notes[0][1]["severity"] == "error" @@ -1169,9 +1176,9 @@ def slow_render( await _load_records(app.screen, (record,)) notes = _capture_notifications(app.screen, monkeypatch) - app.screen.on_search_requested(_search_requested(f"/export {destination}")) + app.screen.request_export(str(destination), selection="records") assert await asyncio.to_thread(started.wait, 2) - app.screen.on_search_requested(_search_requested(f"/export {destination}")) + app.screen.request_export(str(destination), selection="records") await pilot.pause() assert calls == 1 assert any("progress" in str(note[0][0]).lower() for note in notes) @@ -1219,7 +1226,7 @@ def slow_render( await pilot.pause() await _load_records(app.screen, records, selected=0) - app.screen.on_search_requested(_search_requested(f"/export {destination}")) + app.screen.request_export(str(destination), selection="records") assert await asyncio.to_thread(started.wait, 2) app.screen._results.highlighted = 1 app.screen._current_detail_record = records[1] @@ -1348,7 +1355,7 @@ def track_write( await _load_records(app.screen, (record,)) notes = _capture_notifications(app.screen, monkeypatch) - app.screen.on_search_requested(_search_requested(f"/export {destination}")) + app.screen.request_export(str(destination), selection="records") assert await asyncio.to_thread(started.wait, 2) app.screen.on_unmount() release.set() @@ -1477,7 +1484,7 @@ def checked_write( await _load_records(app.screen, (record,)) app.screen._search_input.focus() - app.screen.on_search_requested(_search_requested(f"/export {destination}")) + app.screen.request_export(str(destination), selection="records") assert await asyncio.to_thread(started.wait, 2) await pilot.press("x") await pilot.pause() diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 425b7f5ad..440189d91 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -1,4 +1,4 @@ -"""Pilot contracts for the staged TUI export dialog.""" +"""Pilot contracts for the staged TUI export pane.""" from __future__ import annotations @@ -12,22 +12,23 @@ import typing as t import pytest -from textual.app import App +from textual.app import App, ComposeResult from textual.containers import VerticalScroll from textual.pilot import Pilot from textual.widgets import Input, OptionList, Static +from agentgrep.records import SearchRecord from agentgrep.ui import _export_preferences as export_preferences, _runtime, widgets from agentgrep.ui._export_preferences import ExportPreferences, default_export_directory -from agentgrep.ui.widgets import ExportDialog, export_dialog +from agentgrep.ui.widgets import ExportPane, export_pane from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker -from agentgrep.ui.widgets.export_dialog import ExportDraft, ExportIntent +from agentgrep.ui.widgets.export_pane import ExportDraft, ExportIntent _TIMESTAMP = datetime.datetime(2026, 7, 14, 9, 8, 7, tzinfo=datetime.UTC) class _ExportDialogHost(App[None]): - """Minimal host that pushes one export dialog and captures dismissal.""" + """Minimal host that mounts one export pane and captures removal.""" def __init__( self, @@ -40,24 +41,50 @@ def __init__( timestamp: datetime.datetime = _TIMESTAMP, ) -> None: super().__init__() - self._dialog = ExportDialog( - title=title, - fallback_title="record", + self._dialog = ExportPane( + selected_record=SearchRecord( + kind="prompt", + agent="codex", + store="codex.sessions", + adapter_id="codex.sessions_jsonl.v1", + path=home / "history.jsonl", + text="", + title=title, + ), home=home, preferences=ExportPreferences( directory=directory or str(home), filename_template=template, ), - on_confirm=on_confirm, timestamp=timestamp, ) + self._on_confirm = on_confirm self.dismissed: object = _UNSET + def compose(self) -> ComposeResult: + """Mount the pane in the app's ordinary content region.""" + yield self._dialog + @_runtime.pump_only def on_mount(self) -> None: - """Bind the pump guard and open the dialog.""" + """Bind the pump guard after the pane is mounted.""" _runtime.bind_pump_thread() - self.push_screen(self._dialog, self._capture) + + @_runtime.pump_only + def on_export_pane_confirmed(self, message: ExportPane.Confirmed) -> None: + """Forward the reviewed intent through the isolated test seam.""" + self._on_confirm(message.intent) + + @_runtime.pump_only + async def on_export_pane_close_requested( + self, + message: ExportPane.CloseRequested, + ) -> None: + """Remove the exact pane and record its terminal result.""" + if message.pane is not self._dialog: + return + await message.pane.remove() + self._capture(None) @_runtime.pump_only def on_unmount(self) -> None: @@ -88,9 +115,9 @@ async def _wait_for( pytest.fail("timed out waiting for export-dialog state") -def _dialog(app: _ExportDialogHost) -> ExportDialog: - """Return the mounted export dialog.""" - return t.cast("ExportDialog", app.screen) +def _dialog(app: _ExportDialogHost) -> ExportPane: + """Return the mounted export pane.""" + return app.query_one(ExportPane) def _text(app: _ExportDialogHost, selector: str) -> str: @@ -103,7 +130,7 @@ def _text(app: _ExportDialogHost, selector: str) -> str: def _observe_error_scrolls( monkeypatch: pytest.MonkeyPatch, app: _ExportDialogHost, - dialog: ExportDialog, + dialog: ExportPane, ) -> list[bool]: """Record whether each error scroll ran on the active dialog.""" observations: list[bool] = [] @@ -116,7 +143,7 @@ def observed_scroll_visible( ) -> None: """Record matching calls before forwarding to Textual.""" if widget.id == "export-error": - observations.append(app.screen is dialog) + observations.append(dialog.is_mounted) original_scroll_visible(widget, *args, **kwargs) monkeypatch.setattr(Static, "scroll_visible", observed_scroll_visible) @@ -129,11 +156,11 @@ async def _open_review(app: _ExportDialogHost, pilot: Pilot[None]) -> None: await _wait_for(pilot, lambda: _dialog(app).phase == "review") -def test_export_dialog_interface_is_available_and_internal_values_are_immutable( +def test_export_pane_interface_is_available_and_internal_values_are_immutable( tmp_path: pathlib.Path, ) -> None: - """The package exports the modal but not its immutable internal values.""" - assert widgets.ExportDialog is ExportDialog + """The package exports the pane but not its immutable internal values.""" + assert widgets.ExportPane is ExportPane assert "ExportDraft" not in widgets.__all__ assert "ExportIntent" not in widgets.__all__ assert not hasattr(widgets, "ExportDraft") @@ -147,9 +174,9 @@ def test_export_dialog_interface_is_available_and_internal_values_are_immutable( t.cast("t.Any", intent).destination = tmp_path / "changed.md" -def test_dialog_binding_priorities_preserve_focused_controls() -> None: - """Only modal gestures that must preempt an editor receive priority.""" - bindings = {binding.key: binding for binding in ExportDialog.BINDINGS} +def test_pane_binding_priorities_preserve_focused_controls() -> None: + """Only pane gestures that must preempt an editor receive priority.""" + bindings = {binding.key: binding for binding in ExportPane.BINDINGS} assert bindings["n"].priority is False assert bindings["y"].priority is False @@ -454,7 +481,7 @@ async def test_over_bound_directory_stops_before_compaction( def fail_compaction(_value: str, _home: pathlib.Path) -> t.NoReturn: raise AssertionError(unexpected) - monkeypatch.setattr(export_dialog, "compact_export_directory", fail_compaction) + monkeypatch.setattr(export_pane, "compact_export_directory", fail_compaction) await pilot.press("enter", "enter") await pilot.pause() @@ -565,7 +592,7 @@ async def test_review_uses_compact_pi_confirmation_layout(tmp_path: pathlib.Path async with app.run_test(size=(60, 16)) as pilot: await _open_review(app, pilot) review = app.screen.query_one("#export-review", VerticalScroll) - dialog_body = app.screen.query_one("#export-dialog") + dialog_body = app.screen.query_one("#export-flow") confirm = app.screen.query_one("#export-confirm", OptionList) status = app.screen.query_one("#export-review-status", Static) @@ -575,8 +602,8 @@ async def test_review_uses_compact_pi_confirmation_layout(tmp_path: pathlib.Path " Save", ) assert confirm.region.width <= 12 - assert "-reviewing" in dialog_body.classes - assert dialog_body.region.height <= 12 + assert "-reviewing" not in dialog_body.classes + assert dialog_body.region.height > 12 assert status.styles.dock == "bottom" assert status.region.bottom == review.region.bottom assert _text(app, "#export-review-status") == ("↑↓ move · Enter · Esc edit") @@ -691,7 +718,7 @@ async def test_saving_ignores_cancel_keys(tmp_path: pathlib.Path, key: str) -> N await pilot.press(key) await pilot.pause() - assert app.screen is dialog + assert dialog.is_mounted assert dialog.phase == "saving" @@ -705,7 +732,7 @@ async def test_ctrl_c_dismisses_from_review(tmp_path: pathlib.Path) -> None: await pilot.press("ctrl+c") await _wait_for(pilot, lambda: app.dismissed is None) - assert not app.query(ExportDialog) + assert not app.query(ExportPane) @pytest.mark.parametrize("focused", ["directory", "template"]) @@ -728,7 +755,7 @@ async def test_ctrl_c_clears_focused_edit_before_dismissal( await pilot.press("ctrl+c") await pilot.pause() - assert app.screen is dialog + assert dialog.is_mounted assert dialog.phase == "edit" assert field.value == "" assert field.has_focus @@ -737,7 +764,7 @@ async def test_ctrl_c_clears_focused_edit_before_dismissal( await pilot.press("ctrl+c") await _wait_for(pilot, lambda: app.dismissed is None) - assert not app.query(ExportDialog) + assert not app.query(ExportPane) async def test_escape_dismisses_from_edit(tmp_path: pathlib.Path) -> None: @@ -747,7 +774,7 @@ async def test_escape_dismisses_from_edit(tmp_path: pathlib.Path) -> None: await pilot.press("escape") await _wait_for(pilot, lambda: app.dismissed is None) - assert not app.query(ExportDialog) + assert not app.query(ExportPane) async def test_export_failed_restores_edit_with_values(tmp_path: pathlib.Path) -> None: @@ -842,17 +869,17 @@ async def test_export_succeeded_dismisses(tmp_path: pathlib.Path) -> None: _dialog(app).export_succeeded() await _wait_for(pilot, lambda: app.dismissed is None) - assert not app.query(ExportDialog) + assert not app.query(ExportPane) -async def test_dialog_fits_compact_terminal_without_horizontal_scroll( +async def test_pane_fits_compact_terminal_without_horizontal_scroll( tmp_path: pathlib.Path, ) -> None: - """The single modal stays inside a 60 by 16 terminal in both stages.""" + """The pane stays inside a 60 by 16 terminal in both stages.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) async with app.run_test(size=(60, 16)) as pilot: await pilot.pause() - dialog_body = app.screen.query_one("#export-dialog") + dialog_body = app.screen.query_one("#export-flow") edit = app.screen.query_one("#export-edit", VerticalScroll) assert dialog_body.region.width <= 60 assert dialog_body.region.height <= 16 diff --git a/tests/test_ui_export_pane.py b/tests/test_ui_export_pane.py new file mode 100644 index 000000000..a33310443 --- /dev/null +++ b/tests/test_ui_export_pane.py @@ -0,0 +1,273 @@ +"""Focused contracts for the Pi-like detail-pane export flow.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest +from textual.color import Color +from textual.screen import ModalScreen +from textual.widgets import Input, OptionList +from textual.widgets.input import Selection + +from agentgrep.ui import widgets +from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker +from tests.test_agentgrep_tui_identity import _build_empty_ui_app +from tests.test_ui_export import _load_records, _record, _static_text, _wait_for + +pytestmark = pytest.mark.tui + +ExportPane = t.cast(t.Any, getattr(widgets, "ExportPane", None)) + + +def _forbid_screen_push(*_args: object, **_kwargs: object) -> t.NoReturn: + """Fail if selected-record export enters Textual's screen stack.""" + message = "selected-record export pushed a screen" + raise AssertionError(message) + + +async def _open_review( + pane: t.Any, + pilot: t.Any, + destination: pathlib.Path, +) -> None: + """Fill one destination and wait for its literal review stage.""" + pane.query_one("#export-directory", ExportDirectoryPicker).value = str( + destination.parent, + ) + template = pane.query_one("#export-template", Input) + template.value = destination.name + template.focus() + await pilot.press("enter") + await _wait_for(lambda: pane.phase == "review") + + +@pytest.mark.slow +async def test_e_mounts_export_pane_without_replacing_reader_or_screen( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The shortcut changes detail ownership without a modal or query mutation.""" + assert ExportPane is not None + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "frozen body", ordinal=1, title="Selected") + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + reader = hud.query_one("#detail-scroll") + search = hud._search_input + search.value = "agent:codex selected" + search.selection = Selection(2, 11) + hud._results.focus() + await pilot.pause() + monkeypatch.setattr(app, "push_screen", _forbid_screen_push) + + await pilot.press("e") + await pilot.pause() + + pane = hud.query_one(ExportPane) + assert app.screen is hud + assert not isinstance(pane, ModalScreen) + assert pane.parent is hud.query_one("#detail-column") + assert reader.is_mounted + assert pane.selected_record is record + assert search.value == "agent:codex selected" + assert search.selection == Selection(2, 11) + assert ( + pane.query_one("#export-directory", ExportDirectoryPicker) + .query_one( + Input, + ) + .has_focus + ) + + +@pytest.mark.slow +async def test_typed_export_path_restores_query_selection_and_search_focus( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Slash command text is transient and returns to its exact query draft.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "body", ordinal=1, title="Selected") + destination = tmp_path / "exports" / "chosen.md" + destination.parent.mkdir() + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + search = hud._search_input + search.value = "exact query" + search.selection = Selection(1, 7) + await pilot.pause() + search.value = f"/export {destination}" + search.cursor_position = len(search.value) + search.focus() + await pilot.pause() + + await pilot.press("enter") + await pilot.pause() + + pane = hud.query_one(ExportPane) + assert search.value == "exact query" + assert search.selection == Selection(1, 7) + assert pane.query_one("#export-directory", ExportDirectoryPicker).value == str( + destination.parent, + ) + assert pane.query_one("#export-template", Input).value == destination.name + + await pilot.press("escape") + await _wait_for(lambda: not hud.query(ExportPane)) + + assert search.has_focus + assert search.value == "exact query" + assert search.selection == Selection(1, 7) + + +@pytest.mark.slow +async def test_export_pane_priority_navigation_stays_inside_action( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HUD priority keys become field traversal while export owns the pane.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "body", ordinal=1) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + await pilot.press("e") + pane = hud.query_one(ExportPane) + directory = pane.query_one("#export-directory", ExportDirectoryPicker).query_one( + Input, + ) + template = pane.query_one("#export-template", Input) + assert directory.has_focus + + await pilot.press("ctrl+j") + assert template.has_focus + await pilot.press("ctrl+h") + assert directory.has_focus + await pilot.press("ctrl+l") + assert template.has_focus + await pilot.press("ctrl+k") + assert directory.has_focus + + await pilot.press("ctrl+r", "q") + assert app.screen is hud + assert not hud.query("HistoryRecall") + assert hud.query_one(ExportPane) is pane + + +@pytest.mark.slow +async def test_export_pane_survives_resize_without_mutating_reader_intent( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The action stays full-body across the split breakpoint and restores state.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "body", ordinal=1) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._set_zoomed_pane("results") + hud._results.focus() + await pilot.pause() + hud._detail_opened = False + hud._apply_responsive_layout() + zoomed = hud._zoomed_pane + detail_opened = hud._detail_opened + + await pilot.press("e") + pane = hud.query_one(ExportPane) + await pilot.resize_terminal(80, 24) + await pilot.pause() + assert pane.region.width == hud.query_one("#body").region.width + assert pane.region.height > 0 + await pilot.resize_terminal(120, 30) + await pilot.pause() + assert pane.region.height > 0 + + await pilot.press("escape") + await _wait_for(lambda: not hud.query(ExportPane)) + assert hud._zoomed_pane == zoomed + assert hud._detail_opened is detail_opened + + +@pytest.mark.slow +async def test_export_pane_saves_frozen_record_and_is_fresh_next_time( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Save uses the opening record and teardown permits one clean next session.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + app = _build_empty_ui_app(tmp_path, monkeypatch) + records = ( + _record(tmp_path, "opening selection", ordinal=1, title="First"), + _record(tmp_path, "later selection", ordinal=2, title="Second"), + ) + destination = tmp_path / "frozen.md" + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, records, selected=0) + later_index = next( + index + for index, record in enumerate(hud.filtered_records) + if record is records[1] + ) + hud._results.focus() + await pilot.press("e") + first_pane = hud.query_one(ExportPane) + hud._results.highlighted = later_index + hud._current_detail_record = records[1] + await _open_review(first_pane, pilot, destination) + assert _static_text(first_pane, "#export-review-filename") == destination.name + + await pilot.press("y") + await _wait_for(destination.exists) + await _wait_for(lambda: not hud.query(ExportPane)) + + exported = destination.read_text(encoding="utf-8") + assert "opening selection" in exported + assert "later selection" not in exported + hud._results.highlighted = later_index + hud._results.focus() + await pilot.press("e") + second_pane = hud.query_one(ExportPane) + assert second_pane is not first_pane + assert second_pane.selected_record is records[1] + + +@pytest.mark.slow +async def test_export_review_remains_usable_at_minimum_terminal_height( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Long review content scrolls while the Pi header and footer remain visible.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "body", ordinal=1, title="A long selected title") + destination = tmp_path / ("wrapped-" + "x" * 70 + ".md") + async with app.run_test(size=(24, 8)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + await pilot.press("e") + pane = hud.query_one(ExportPane) + await _open_review(pane, pilot, destination) + + header = pane.query_one("#export-pane-header") + assert header.region.height == 1 + assert header.styles.color == Color.parse(app.theme_variables["accent"]) + assert pane.query_one("#export-review-status").region.height == 1 + confirm = pane.query_one("#export-confirm", OptionList) + review = pane.query_one("#export-review") + assert review.region.bottom <= pane.region.bottom + assert confirm.region.overlaps(pane.region) + assert confirm.highlighted == 0 + assert confirm.region.height > 0 From ff05150f03a197a3dbb205513d3b59f29178f242 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:28:12 -0500 Subject: [PATCH 65/71] agentgrep(fix[tui]): Retain slash queries why: Leading-slash text that is not a registered command is a valid search. Treating every slash-prefixed value as transient restored an older query after the next successful command. what: - Remember the active input after dispatch classifies it as a literal query. - Cover exact value and selection restoration after a leading-slash search. --- src/agentgrep/ui/layouts/_hud_search.py | 1 + src/agentgrep/ui/layouts/greplog.py | 1 + src/agentgrep/ui/widgets/inputs.py | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/src/agentgrep/ui/layouts/_hud_search.py b/src/agentgrep/ui/layouts/_hud_search.py index e86a68629..3bf0b1940 100644 --- a/src/agentgrep/ui/layouts/_hud_search.py +++ b/src/agentgrep/ui/layouts/_hud_search.py @@ -367,6 +367,7 @@ def on_search_requested(self, message: SearchRequested) -> None: if self._dispatch_slash_text(text) is not None: return self._remember_active_search_text(text) + self._search_input.remember_query_draft() self._workflow.on_query(self, text) # --- WorkflowHost surface: the active workflow drives the layout here ----- diff --git a/src/agentgrep/ui/layouts/greplog.py b/src/agentgrep/ui/layouts/greplog.py index 195400ffd..3e804cab7 100644 --- a/src/agentgrep/ui/layouts/greplog.py +++ b/src/agentgrep/ui/layouts/greplog.py @@ -278,6 +278,7 @@ def on_search_requested(self, message: SearchRequested) -> None: if self._dispatch_slash_text(text) is not None: return self._remember_active_search_text(text) + self._search_input.remember_query_draft() self._workflow.on_query(self, text) def action_stop_search(self) -> None: diff --git a/src/agentgrep/ui/widgets/inputs.py b/src/agentgrep/ui/widgets/inputs.py index e6a8dadf5..57c88ed9f 100644 --- a/src/agentgrep/ui/widgets/inputs.py +++ b/src/agentgrep/ui/widgets/inputs.py @@ -413,6 +413,11 @@ def clear_query_draft(self) -> None: self._query_draft_value = "" self._query_draft_selection = Selection.cursor(0) + def remember_query_draft(self) -> None: + """Remember the current value after dispatch classifies it as a query.""" + self._query_draft_value = self.value + self._query_draft_selection = self.selection + def restore_query_draft(self, *, focus: bool = True) -> None: """Restore the last non-command value without synthesizing keystrokes.""" value, selection = self.query_draft From 7c8492b31dfe0e4eeb8edcf43d5e63a60706c1ba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:29:23 -0500 Subject: [PATCH 66/71] agentgrep(fix[tui]): Contain export focus why: A second Tab could leave the export state for the live search input, allowing hidden searches whose results no longer matched the restored query. what: - Wrap Tab and Shift-Tab across export editors and the review choice. - Preserve Tab acceptance for an open directory completion. - Cover edit and compact-review focus containment with Pilot. --- src/agentgrep/ui/widgets/directory_popup.py | 5 +++ src/agentgrep/ui/widgets/export_pane.py | 34 +++++++++++++++++++++ tests/test_ui_export_pane.py | 10 ++++++ 3 files changed, 49 insertions(+) diff --git a/src/agentgrep/ui/widgets/directory_popup.py b/src/agentgrep/ui/widgets/directory_popup.py index b1a6f051b..e9518f977 100644 --- a/src/agentgrep/ui/widgets/directory_popup.py +++ b/src/agentgrep/ui/widgets/directory_popup.py @@ -271,6 +271,11 @@ def focus_input(self) -> None: """Focus the picker-owned directory field.""" self._input.focus() + @_runtime.pump_only + def accept_completion(self) -> bool: + """Accept the highlighted directory when completion is open.""" + return self._accept_highlighted() + @_runtime.pump_only def on_input_changed(self, event: Input.Changed) -> None: """Debounce completion for the latest literal input value.""" diff --git a/src/agentgrep/ui/widgets/export_pane.py b/src/agentgrep/ui/widgets/export_pane.py index 551f04e53..a9ee8aef4 100644 --- a/src/agentgrep/ui/widgets/export_pane.py +++ b/src/agentgrep/ui/widgets/export_pane.py @@ -168,6 +168,8 @@ def __init__(self, pane: ExportPane, intent: ExportIntent) -> None: Binding("ctrl+l", "editor_next", "Next field", priority=True, show=False), Binding("up", "editor_previous", "Previous field", show=False), Binding("down", "editor_next", "Next field", show=False), + Binding("tab", "editor_tab", "Next field", show=False), + Binding("shift+tab", "editor_backtab", "Previous field", show=False), Binding("n", "review_no", "No", show=False), Binding("y", "review_save", "Save", show=False), ] @@ -390,6 +392,38 @@ def action_editor_next(self) -> None: if directory.has_focus: self.query_one("#export-template", Input).focus() + @_runtime.pump_only + def action_editor_tab(self) -> None: + """Keep forward traversal inside the active export state.""" + if self._phase == "review": + self.query_one("#export-confirm", OptionList).focus() + return + if self._phase != "edit": + return + picker = self.query_one("#export-directory", ExportDirectoryPicker) + directory = picker.query_one(Input) + template = self.query_one("#export-template", Input) + if directory.has_focus: + if not picker.accept_completion(): + template.focus() + return + picker.focus_input() + + @_runtime.pump_only + def action_editor_backtab(self) -> None: + """Keep reverse traversal inside the active export state.""" + if self._phase == "review": + self.query_one("#export-confirm", OptionList).focus() + return + if self._phase != "edit": + return + picker = self.query_one("#export-directory", ExportDirectoryPicker) + directory = picker.query_one(Input) + if directory.has_focus: + self.query_one("#export-template", Input).focus() + return + picker.focus_input() + @_runtime.pump_only def action_review_no(self) -> None: """Return to the retained draft only while reviewing.""" diff --git a/tests/test_ui_export_pane.py b/tests/test_ui_export_pane.py index a33310443..bb42ecfe8 100644 --- a/tests/test_ui_export_pane.py +++ b/tests/test_ui_export_pane.py @@ -156,6 +156,14 @@ async def test_export_pane_priority_navigation_stays_inside_action( await pilot.press("ctrl+k") assert directory.has_focus + await pilot.press("tab") + assert template.has_focus + await pilot.press("tab") + assert directory.has_focus + await pilot.press("shift+tab") + assert template.has_focus + assert app.focused is not hud._search_input + await pilot.press("ctrl+r", "q") assert app.screen is hud assert not hud.query("HistoryRecall") @@ -271,3 +279,5 @@ async def test_export_review_remains_usable_at_minimum_terminal_height( assert confirm.region.overlaps(pane.region) assert confirm.highlighted == 0 assert confirm.region.height > 0 + await pilot.press("tab", "shift+tab") + assert confirm.has_focus From 6b40338fa30f4fd409ce19ec533889383c52982f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:29:51 -0500 Subject: [PATCH 67/71] agentgrep(fix[tui]): Guard pane mounting why: Textual mounts dynamically added children after the opening pump turn. A same-turn priority key could query export controls before compose completed. what: - Track when the pane's composed controls are ready for routed actions. - Ignore edit, cancel, and review actions during the deferred mount window. - Reproduce the race without an intervening Pilot pause. --- src/agentgrep/ui/widgets/export_pane.py | 24 +++++++++++++++++++---- tests/test_ui_export_pane.py | 26 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/agentgrep/ui/widgets/export_pane.py b/src/agentgrep/ui/widgets/export_pane.py index a9ee8aef4..5c48393bd 100644 --- a/src/agentgrep/ui/widgets/export_pane.py +++ b/src/agentgrep/ui/widgets/export_pane.py @@ -243,6 +243,7 @@ def __init__( directory=compact_export_directory(preferences.directory, home), ) self._phase: ExportPhase = "edit" + self._ready = False self._validation_generation = 0 self._error_reveal_generation = 0 self._pending_error_reveal: tuple[int, str] | None = None @@ -259,6 +260,11 @@ def selected_record(self) -> SearchRecord: """Return the exact record frozen when this pane was created.""" return self._selected_record + @property + def ready(self) -> bool: + """Return whether composed children are ready for routed actions.""" + return self._ready + @_runtime.pump_only def compose(self) -> ComposeResult: """Compose one quiet edit/review flow with literal output surfaces.""" @@ -299,10 +305,12 @@ def on_mount(self) -> None: """Render the frozen preview and focus the directory editor.""" self._refresh_preview() self.query_one("#export-directory", ExportDirectoryPicker).focus_input() + self._ready = True @_runtime.pump_only def on_unmount(self) -> None: """Invalidate and cancel validator work before teardown.""" + self._ready = False self._invalidate_error_reveal() self._validation_generation += 1 self.workers.cancel_group(self, _VALIDATION_WORKER_GROUP) @@ -346,6 +354,8 @@ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) @_runtime.pump_only def action_escape(self) -> None: """Return from review or cancel before a durable save begins.""" + if not self._ready: + return if self._phase == "saving": return if self._phase == "review": @@ -356,6 +366,8 @@ def action_escape(self) -> None: @_runtime.pump_only def action_cancel(self) -> None: """Clear the focused edit once, or dismiss before durable saving.""" + if not self._ready: + return if self._phase == "saving": return if self._phase == "edit": @@ -374,7 +386,7 @@ def action_cancel(self) -> None: @_runtime.pump_only def action_editor_previous(self) -> None: """Move to the previous editor without wrapping at the first field.""" - if self._phase != "edit": + if not self._ready or self._phase != "edit": return template = self.query_one("#export-template", Input) if template.has_focus: @@ -383,7 +395,7 @@ def action_editor_previous(self) -> None: @_runtime.pump_only def action_editor_next(self) -> None: """Move to the next editor without wrapping at the final field.""" - if self._phase != "edit": + if not self._ready or self._phase != "edit": return directory = self.query_one( "#export-directory", @@ -395,6 +407,8 @@ def action_editor_next(self) -> None: @_runtime.pump_only def action_editor_tab(self) -> None: """Keep forward traversal inside the active export state.""" + if not self._ready: + return if self._phase == "review": self.query_one("#export-confirm", OptionList).focus() return @@ -412,6 +426,8 @@ def action_editor_tab(self) -> None: @_runtime.pump_only def action_editor_backtab(self) -> None: """Keep reverse traversal inside the active export state.""" + if not self._ready: + return if self._phase == "review": self.query_one("#export-confirm", OptionList).focus() return @@ -427,13 +443,13 @@ def action_editor_backtab(self) -> None: @_runtime.pump_only def action_review_no(self) -> None: """Return to the retained draft only while reviewing.""" - if self._phase == "review": + if self._ready and self._phase == "review": self._show_edit() @_runtime.pump_only def action_review_save(self) -> None: """Delegate the reviewed intent only while reviewing.""" - if self._phase == "review": + if self._ready and self._phase == "review": self._confirm() @_runtime.pump_only diff --git a/tests/test_ui_export_pane.py b/tests/test_ui_export_pane.py index bb42ecfe8..78a930b88 100644 --- a/tests/test_ui_export_pane.py +++ b/tests/test_ui_export_pane.py @@ -84,6 +84,32 @@ async def test_e_mounts_export_pane_without_replacing_reader_or_screen( ) +@pytest.mark.slow +async def test_export_pane_ignores_actions_during_deferred_mount( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Same-turn priority keys cannot query children before compose completes.""" + app = _build_empty_ui_app(tmp_path, monkeypatch) + record = _record(tmp_path, "body", ordinal=1) + async with app.run_test(size=(120, 30)) as pilot: + await pilot.pause() + hud = app.screen + await _load_records(hud, (record,)) + hud._results.focus() + + assert hud.open_export_pane("", selected_record=record) + pane = hud._export_pane + assert pane is not None + assert not pane.is_mounted + hud.action_focus_pane_down() + hud.action_smart_quit() + + await pilot.pause() + assert pane.is_mounted + assert hud._export_pane is pane + + @pytest.mark.slow async def test_typed_export_path_restores_query_selection_and_search_focus( tmp_path: pathlib.Path, From 03740d64ef57231561dca02b09344ec913086615 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:31:56 -0500 Subject: [PATCH 68/71] agentgrep(fix[tui]): Keep wide results visible why: The export action should replace the detail reader on wide terminals, not obscure the results that give the action its selection context. what: - Preserve the results column beside the export pane in wide layouts. - Keep the focused full-body export presentation for stacked terminals. - Cover responsive transitions without changing reader state. --- src/agentgrep/ui/styles.tcss | 12 +++++++++--- tests/test_ui_export_pane.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/agentgrep/ui/styles.tcss b/src/agentgrep/ui/styles.tcss index 11a8892f4..4d3ec628d 100644 --- a/src/agentgrep/ui/styles.tcss +++ b/src/agentgrep/ui/styles.tcss @@ -433,17 +433,23 @@ Screen { #body.-zoom-detail > #results-column { display: none; } -/* A transient detail action owns the body without rewriting the user's - responsive, collapsed, or logical zoom state. Removing this one class +/* A transient detail action replaces only the right reader on wide terminals. + In the stacked mobile layout it owns the body. Neither presentation rewrites + the user's collapsed or logical zoom state, so removing this one class reveals the exact reader layout that was already underneath it. */ #body.-export-pane > #results-column { - display: none; + display: block; + width: 1fr; + height: 1fr; } #body.-export-pane > #detail-column { display: block; width: 1fr; height: 1fr; } +#body.-stacked.-export-pane > #results-column { + display: none; +} #detail-column.-exporting > #detail-header, #detail-column.-exporting > #detail-scroll, #detail-column.-exporting > #detail-find, diff --git a/tests/test_ui_export_pane.py b/tests/test_ui_export_pane.py index 78a930b88..f77be3818 100644 --- a/tests/test_ui_export_pane.py +++ b/tests/test_ui_export_pane.py @@ -68,9 +68,15 @@ async def test_e_mounts_export_pane_without_replacing_reader_or_screen( await pilot.pause() pane = hud.query_one(ExportPane) + body = hud.query_one("#body") + results_column = hud.query_one("#results-column") + detail_column = hud.query_one("#detail-column") assert app.screen is hud assert not isinstance(pane, ModalScreen) assert pane.parent is hud.query_one("#detail-column") + assert results_column.region.width > 0 + assert pane.region.width == detail_column.region.width + assert pane.region.width < body.region.width assert reader.is_mounted assert pane.selected_record is record assert search.value == "agent:codex selected" @@ -218,13 +224,19 @@ async def test_export_pane_survives_resize_without_mutating_reader_intent( await pilot.press("e") pane = hud.query_one(ExportPane) + results_column = hud.query_one("#results-column") + assert results_column.region.width > 0 await pilot.resize_terminal(80, 24) + await _wait_for(lambda: hud.query_one("#body").has_class("-stacked")) await pilot.pause() assert pane.region.width == hud.query_one("#body").region.width assert pane.region.height > 0 + assert results_column.region.width == 0 await pilot.resize_terminal(120, 30) + await _wait_for(lambda: not hud.query_one("#body").has_class("-stacked")) await pilot.pause() assert pane.region.height > 0 + assert results_column.region.width > 0 await pilot.press("escape") await _wait_for(lambda: not hud.query(ExportPane)) From ae345313af518b7572b34b3d72957c642286f632 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 14 Jul 2026 18:32:04 -0500 Subject: [PATCH 69/71] agentgrep(docs[tui]): Name thread export why: Only the direct thread command uses an automatic canonical-ID name; the reviewed record command previews and confirms a templated filename. what: - Attribute automatic private naming specifically to /export-thread. - Pin the command distinction in the export documentation contract. --- docs/cli/export.md | 4 ++-- tests/test_export_docs.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/cli/export.md b/docs/cli/export.md index 14591b935..d556cd653 100644 --- a/docs/cli/export.md +++ b/docs/cli/export.md @@ -40,8 +40,8 @@ needed, writes only the reviewed explicit no-clobber artifact, then attempts to write the TUI-private preference file. If the artifact name already exists, agentgrep returns to the same draft instead of replacing the file or silently choosing another name; a later preference failure does not erase a completed -artifact. Automatic private exports requested by the HUD slash commands keep -their canonical-ID names. CLI and MCP do not consume the TUI preference: the +artifact. The one-shot `/export-thread` command keeps its automatic private +canonical-ID name. CLI and MCP do not consume the TUI preference: the CLI still uses standard output or an explicit `--output` path, and MCP still returns a bounded inline artifact, accepts no local destination, and gains no filesystem write authority. diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index 5306c1a97..f4785d654 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -152,6 +152,10 @@ def test_export_guide_defines_reviewed_tui_destination() -> None: ) assert re.search(r"local time.*filesystem-safe", normalized) assert re.search(r"\bno returns to editing\b", normalized) + assert re.search( + r"one-shot `/export-thread` command.*automatic private canonical-id name", + normalized, + ) assert re.search(r"cli and mcp do not consume the tui preference", normalized) assert re.search( r"mcp.*accepts no local destination.*no filesystem write authority", From 82d940441a731e2532ec5c7cf1b9e5c7460dee65 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 18 Jul 2026 08:27:30 -0500 Subject: [PATCH 70/71] agentgrep(fix[mcp]): Isolate export metadata why: Reusing the FastMCP decorator mutates function metadata shared with the docs shim, making schema results depend on registration order. what: - Build dedicated Tool objects when registering docs schema functions. - Keep the live export docstring and documented schema text aligned. --- docs/_ext/agentgrep_fastmcp.py | 4 ++-- src/agentgrep/mcp/tools/export_tools.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/_ext/agentgrep_fastmcp.py b/docs/_ext/agentgrep_fastmcp.py index 1f8e35768..4fe6db703 100644 --- a/docs/_ext/agentgrep_fastmcp.py +++ b/docs/_ext/agentgrep_fastmcp.py @@ -152,10 +152,10 @@ async def export_records( ] = "records", include_bodies: t.Annotated[ bool, - Field(description="Include prompt and history text in the artifact."), + Field(description="Include prompt/history text in the artifact."), ] = False, ) -> ExportRecordsResponse: - """Return selected search refs as one bounded inline artifact.""" + """Return selected refs as one NDJSON or Markdown TextContent artifact with structured export metadata.""" # noqa: E501 raise NotImplementedError(DOCS_ONLY_MESSAGE) diff --git a/src/agentgrep/mcp/tools/export_tools.py b/src/agentgrep/mcp/tools/export_tools.py index 3c2b128ee..93be913ce 100644 --- a/src/agentgrep/mcp/tools/export_tools.py +++ b/src/agentgrep/mcp/tools/export_tools.py @@ -136,6 +136,7 @@ async def export_records_tool( Field(description="Include prompt/history text in the artifact."), ] = False, ) -> ToolResult: + """Return selected refs as one NDJSON or Markdown TextContent artifact with structured export metadata.""" # noqa: E501 try: request = ExportRecordsRequest( refs=refs, From 8c1b6c14c86a69224d46a62c29edd932eaf41700 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 18 Jul 2026 08:27:30 -0500 Subject: [PATCH 71/71] agentgrep(fix[tui]): Use public watchers why: Private Textual reactive hooks couple query-draft state to framework internals and lose cursor-only selections. what: - Observe value and selection through public watch registrations. - Guard the public pump callbacks and cursor-only restoration behavior. --- src/agentgrep/ui/layouts/hud.py | 4 +- src/agentgrep/ui/widgets/inputs.py | 8 +- tests/_tui_export_support.py | 45 ++++ tests/test_cli_export.py | 283 ++++++++++++++++++++++++ tests/test_export_docs.py | 3 + tests/test_ui_export.py | 10 +- tests/test_ui_export_dialog.py | 38 ++++ tests/test_ui_export_directory_popup.py | 10 + tests/test_ui_export_pane.py | 6 +- tests/test_ui_export_preferences.py | 2 + 10 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 tests/_tui_export_support.py diff --git a/src/agentgrep/ui/layouts/hud.py b/src/agentgrep/ui/layouts/hud.py index 1c7a3e935..1052f09a9 100644 --- a/src/agentgrep/ui/layouts/hud.py +++ b/src/agentgrep/ui/layouts/hud.py @@ -159,9 +159,7 @@ class HudLayout(_HudSearchBase): """Search box, streaming results list, detail pane, and status chrome.""" ZOOM_ARGUMENT_HINT: t.ClassVar[str] = "[results|detail]" - EXTRA_SLASH_COMMANDS: t.ClassVar[tuple[commands.SlashCommand, ...]] = ( - commands.export_commands() - ) + EXTRA_SLASH_COMMANDS: t.ClassVar[tuple[commands.SlashCommand, ...]] = commands.export_commands() # ``priority=True`` on the directional ``ctrl+hjkl`` bindings pushes # them into Textual's priority dispatch lane so they win over any diff --git a/src/agentgrep/ui/widgets/inputs.py b/src/agentgrep/ui/widgets/inputs.py index 57c88ed9f..4a8e6c14a 100644 --- a/src/agentgrep/ui/widgets/inputs.py +++ b/src/agentgrep/ui/widgets/inputs.py @@ -427,17 +427,15 @@ def restore_query_draft(self, *, focus: bool = True) -> None: self.focus() @_runtime.pump_only - def _watch_value(self, value: str) -> None: + def _capture_query_draft_value(self, value: str) -> None: """Remember ordinary query edits while slash invocations stay transient.""" - super()._watch_value(value) if not value.lstrip().startswith("/"): self._query_draft_value = value self._query_draft_selection = self.selection @_runtime.pump_only - def _watch_selection(self, selection: Selection) -> None: + def _capture_query_draft_selection(self, selection: Selection) -> None: """Retain the exact query cursor or selection independently of edits.""" - super()._watch_selection(selection) if not self.value.lstrip().startswith("/"): self._query_draft_value = self.value self._query_draft_selection = selection @@ -459,6 +457,8 @@ def on_mount(self) -> None: ``border_title`` at runtime to surface live state (scope, agent, mode) instead. Alignment and color live in ``styles.tcss``. """ + self.watch(self, "value", self._capture_query_draft_value, init=False) + self.watch(self, "selection", self._capture_query_draft_selection, init=False) if self._label is not None: self.border_title = self._label self._sync_submit_hint(self.value) diff --git a/tests/_tui_export_support.py b/tests/_tui_export_support.py new file mode 100644 index 000000000..4def3cb85 --- /dev/null +++ b/tests/_tui_export_support.py @@ -0,0 +1,45 @@ +"""Shared constructors for focused Textual export tests.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +import agentgrep + + +def _search_requested(text: str) -> object: + """Build a ``SearchRequested`` message carrying ``text``.""" + from agentgrep.progress import SearchRequestedPayload + from agentgrep.ui.widgets import SearchRequested + + return SearchRequested(payload=SearchRequestedPayload(text=text)) + + +def _build_empty_ui_app( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> t.Any: + """Build an isolated streaming UI with a no-op search worker.""" + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + monkeypatch.setattr(agentgrep, "run_search_query", lambda *args, **kwargs: []) + query = agentgrep.SearchQuery( + terms=(), + scope="prompts", + any_term=False, + regex=False, + case_sensitive=False, + agents=("codex",), + limit=None, + ) + return agentgrep.build_streaming_ui_app( + home, + query, + control=agentgrep.SearchControl(), + ) diff --git a/tests/test_cli_export.py b/tests/test_cli_export.py index 009d4b927..4462de6c3 100644 --- a/tests/test_cli_export.py +++ b/tests/test_cli_export.py @@ -2,6 +2,9 @@ from __future__ import annotations +import collections.abc as cabc +import dataclasses +import itertools import json import os import pathlib @@ -15,6 +18,275 @@ import agentgrep.cli.render as cli_render +def load_agentgrep_module() -> object: + """Return the installed package for facade-compatibility assertions.""" + return agentgrep + + +class ExportParserCase(t.NamedTuple): + """One export parser permutation.""" + + export_format: str + output_args: tuple[str, ...] + expected_output: str + include_bodies: bool + scope: str + + +EXPORT_PARSER_CASES: tuple[ExportParserCase, ...] = tuple( + ExportParserCase( + export_format=export_format, + output_args=output_args, + expected_output=expected_output, + include_bodies=include_bodies, + scope=scope, + ) + for export_format, (output_args, expected_output), include_bodies, scope in itertools.product( + ("ndjson", "markdown"), + ( + ((), "-"), + (("-o", "-"), "-"), + (("--output", "export.out"), "export.out"), + ), + (True, False), + ("prompts", "conversations", "all"), + ) +) + + +@pytest.mark.parametrize( + "case", + EXPORT_PARSER_CASES, + ids=( + f"{case.export_format}-" + f"{'default' if not case.output_args else case.output_args[0].lstrip('-') or 'stdout'}-" + f"{'bodies' if case.include_bodies else 'no-bodies'}-{case.scope}" + for case in EXPORT_PARSER_CASES + ), +) +def test_parse_export_covers_format_sink_bodies_and_scope_matrix( + case: ExportParserCase, +) -> None: + """Every documented export parser permutation yields typed arguments.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + argv = [ + "export", + "needle", + "--format", + case.export_format, + *case.output_args, + "--scope", + case.scope, + ] + if not case.include_bodies: + argv.append("--no-bodies") + + parsed = agentgrep.parse_args(argv) + + assert isinstance(parsed, agentgrep.ExportArgs) + assert parsed.format == case.export_format + assert parsed.output == case.expected_output + assert parsed.include_bodies is case.include_bodies + assert parsed.scope == case.scope + + +def test_parse_export_defaults_have_exact_typed_contract() -> None: + """Export defaults to bounded body-inclusive NDJSON on stdout.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + + parsed = agentgrep.parse_args(["export", "Needle"]) + + assert isinstance(parsed, agentgrep.ExportArgs) + assert dataclasses.asdict(parsed) == { + "terms": ("Needle",), + "agents": agentgrep.AGENT_CHOICES, + "scope": "prompts", + "case_sensitive": False, + "limit": 100, + "format": "ndjson", + "output": "-", + "force": False, + "include_bodies": True, + "compiled": None, + "raw_query": "Needle", + } + + +def test_parse_export_supports_agent_aliases_and_case_sensitive_search() -> None: + """Export shares search's agent selection and case controls.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + + all_agents = agentgrep.parse_args(["export", "needle", "--agent", "all"]) + selected_agents = agentgrep.parse_args( + [ + "export", + "needle", + "--agent", + "codex", + "--agent", + "claude", + "--case-sensitive", + ], + ) + + assert isinstance(all_agents, agentgrep.ExportArgs) + assert all_agents.agents == agentgrep.AGENT_CHOICES + assert isinstance(selected_agents, agentgrep.ExportArgs) + assert selected_agents.agents == ("codex", "claude") + assert selected_agents.case_sensitive is True + + +def test_parse_export_reuses_compiled_query_semantics() -> None: + """Field predicates compile while residual terms retain search behavior.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + + parsed = agentgrep.parse_args(["export", "scope:conversations", "bliss"]) + + assert isinstance(parsed, agentgrep.ExportArgs) + assert parsed.terms == ("bliss",) + assert parsed.scope == "conversations" + assert parsed.compiled is not None + assert parsed.raw_query == "scope:conversations bliss" + + +@pytest.mark.parametrize("limit", ["-1", "0", "1001"]) +def test_parse_export_rejects_limits_outside_closed_range( + limit: str, + capsys: pytest.CaptureFixture[str], +) -> None: + """The export cap is constrained to 1 through 1000 inclusive.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + + with pytest.raises(SystemExit) as exc_info: + agentgrep.parse_args(["export", "needle", "--limit", limit]) + + assert exc_info.value.code == 2 + assert "--limit must be between 1 and 1000" in capsys.readouterr().err + + +def test_parse_export_accepts_limit_range_endpoints() -> None: + """Both documented export limit endpoints are accepted.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + + low = agentgrep.parse_args(["export", "needle", "--limit", "1"]) + high = agentgrep.parse_args(["export", "needle", "--limit", "1000"]) + + assert isinstance(low, agentgrep.ExportArgs) + assert low.limit == 1 + assert isinstance(high, agentgrep.ExportArgs) + assert high.limit == 1000 + + +@pytest.mark.parametrize("output_args", [(), ("-o", "-")]) +def test_parse_export_rejects_force_for_stdout( + output_args: tuple[str, ...], + capsys: pytest.CaptureFixture[str], +) -> None: + """Force is meaningful only for an explicit file destination.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + + with pytest.raises(SystemExit) as exc_info: + agentgrep.parse_args(["export", "needle", *output_args, "--force"]) + + assert exc_info.value.code == 2 + assert "--force requires a file output" in capsys.readouterr().err + + +def test_parse_export_allows_force_for_file_output() -> None: + """Explicit file output may opt into replacement.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + + parsed = agentgrep.parse_args(["export", "needle", "-o", "export.ndjson", "--force"]) + + assert isinstance(parsed, agentgrep.ExportArgs) + assert parsed.output == "export.ndjson" + assert parsed.force is True + + +def test_bare_export_prints_subcommand_help( + capsys: pytest.CaptureFixture[str], +) -> None: + """Bare export explains the command instead of scanning every store.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + + parsed = agentgrep.parse_args(["export"]) + + assert parsed is None + output = capsys.readouterr().out + assert "usage: agentgrep export" in output + assert "--no-bodies" in output + + +@pytest.mark.slow +def test_export_help_keeps_persistence_module_off_cold_path() -> None: + """Root and export help do not import renderer or TUI persistence modules.""" + runner = """ +import agentgrep +import contextlib +import io +import sys + +root_output = io.StringIO() +with contextlib.redirect_stdout(root_output): + assert agentgrep.main([]) == 0 +assert "export" in root_output.getvalue() +assert "agentgrep.record_export" not in sys.modules +assert "agentgrep.ui._export_preferences" not in sys.modules + +export_output = io.StringIO() +with contextlib.redirect_stdout(export_output): + try: + agentgrep.main(["export", "--help"]) + except SystemExit as exc: + assert exc.code == 0 +assert "usage: agentgrep export" in export_output.getvalue() +assert "agentgrep.record_export" not in sys.modules +assert "agentgrep.ui._export_preferences" not in sys.modules +""" + + completed = subprocess.run( + [sys.executable, "-c", runner], + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + + +def test_main_dispatches_export_args(monkeypatch: pytest.MonkeyPatch) -> None: + """The compatibility facade routes typed export args to the thin command.""" + agentgrep = t.cast("t.Any", load_agentgrep_module()) + args = agentgrep.ExportArgs( + terms=("bliss",), + agents=("codex",), + scope="prompts", + case_sensitive=False, + limit=100, + format="ndjson", + output="-", + force=False, + include_bodies=True, + compiled=None, + raw_query="bliss", + ) + calls: list[object] = [] + + def parse_args(argv: cabc.Sequence[str] | None = None) -> object: + assert argv == ["export", "bliss"] + return args + + def run_export_command(received: object) -> int: + calls.append(received) + return 7 + + monkeypatch.setattr(agentgrep, "parse_args", parse_args) + monkeypatch.setattr(agentgrep, "run_export_command", run_export_command) + + assert agentgrep.main(["export", "bliss"]) == 7 + assert calls == [args] + + def _write_jsonl(path: pathlib.Path, rows: list[object]) -> None: """Write one fixture store as newline-delimited JSON.""" path.parent.mkdir(parents=True, exist_ok=True) @@ -82,6 +354,7 @@ def _export_env(home: pathlib.Path) -> dict[str, str]: @pytest.mark.parametrize("output_args", [(), ("-o", "-")]) +@pytest.mark.slow def test_export_ndjson_writes_default_and_explicit_stdout( export_home: pathlib.Path, output_args: tuple[str, ...], @@ -105,6 +378,7 @@ def test_export_ndjson_writes_default_and_explicit_stdout( assert completed.stderr == "" +@pytest.mark.slow def test_export_ndjson_no_bodies_omits_text_field(export_home: pathlib.Path) -> None: """The body opt-out removes text without changing selected records.""" completed = _run_export_cli( @@ -122,6 +396,7 @@ def test_export_ndjson_no_bodies_omits_text_field(export_home: pathlib.Path) -> assert completed.stderr == "" +@pytest.mark.slow def test_export_markdown_writes_stdout(export_home: pathlib.Path) -> None: """Markdown stdout uses the approved deterministic records renderer.""" completed = _run_export_cli( @@ -141,6 +416,7 @@ def test_export_markdown_writes_stdout(export_home: pathlib.Path) -> None: assert completed.stderr == "" +@pytest.mark.slow def test_export_writes_explicit_file_without_stdout( export_home: pathlib.Path, tmp_path: pathlib.Path, @@ -167,6 +443,7 @@ def test_export_writes_explicit_file_without_stdout( assert completed.stderr == "" +@pytest.mark.slow def test_export_file_refusal_and_explicit_force( export_home: pathlib.Path, tmp_path: pathlib.Path, @@ -207,6 +484,7 @@ def test_export_file_refusal_and_explicit_force( assert replaced.stderr == "" +@pytest.mark.slow def test_export_protects_every_selected_record_source_path( export_home: pathlib.Path, ) -> None: @@ -283,6 +561,7 @@ def test_export_protects_every_selected_record_source_path( ], ids=("selected-agent", "outside-agent-non-default"), ) +@pytest.mark.slow def test_export_force_protects_unmatched_discovered_source( export_home: pathlib.Path, relative_path: pathlib.Path, @@ -315,6 +594,7 @@ def test_export_force_protects_unmatched_discovered_source( assert "Traceback" not in completed.stderr +@pytest.mark.slow def test_export_zero_matches_uses_search_exit_status(export_home: pathlib.Path) -> None: """An empty NDJSON selection emits no rows and exits with no-match status.""" completed = _run_export_cli( @@ -329,6 +609,7 @@ def test_export_zero_matches_uses_search_exit_status(export_home: pathlib.Path) assert completed.stderr == "" +@pytest.mark.slow def test_export_stdout_is_deterministic_across_reruns(export_home: pathlib.Path) -> None: """Repeated reads of unchanged stores produce byte-identical stdout.""" first = _run_export_cli(export_home, "bliss", "--agent", "codex") @@ -339,6 +620,7 @@ def test_export_stdout_is_deterministic_across_reruns(export_home: pathlib.Path) assert first.stderr == second.stderr == "" +@pytest.mark.slow def test_export_invalid_markdown_text_is_path_free( tmp_path: pathlib.Path, ) -> None: @@ -600,6 +882,7 @@ def test_export_broken_pipe_is_path_free( assert "Traceback" not in error +@pytest.mark.slow def test_export_real_broken_pipe_exits_without_shutdown_traceback( export_home: pathlib.Path, ) -> None: diff --git a/tests/test_export_docs.py b/tests/test_export_docs.py index f4785d654..2a79c1180 100644 --- a/tests/test_export_docs.py +++ b/tests/test_export_docs.py @@ -7,10 +7,13 @@ import re import typing as t +import pytest from pydantic import TypeAdapter from agentgrep.mcp import ExportRecordsResponse +pytestmark = pytest.mark.documentation + _REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] diff --git a/tests/test_ui_export.py b/tests/test_ui_export.py index 24e0f11e2..05c86743a 100644 --- a/tests/test_ui_export.py +++ b/tests/test_ui_export.py @@ -14,7 +14,6 @@ from textual.widgets import HelpPanel, Input, Static from agentgrep import identity, record_export -from agentgrep.progress import SearchRequestedPayload from agentgrep.records import RecordPosition, SearchRecord from agentgrep.ui import _export_preferences, _runtime, app as ui_app from agentgrep.ui._export_preferences import ( @@ -25,18 +24,13 @@ save_export_preferences, ) from agentgrep.ui.layouts import hud as hud_module -from agentgrep.ui.widgets import ExportPane, FilterCompleted, SearchRequested +from agentgrep.ui.widgets import ExportPane, FilterCompleted from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker -from tests.test_agentgrep_tui_identity import _build_empty_ui_app +from tests._tui_export_support import _build_empty_ui_app, _search_requested pytestmark = pytest.mark.tui -def _search_requested(text: str) -> SearchRequested: - """Build one search request for direct HUD handler coverage.""" - return SearchRequested(payload=SearchRequestedPayload(text=text)) - - def _record( tmp_path: pathlib.Path, text: str, diff --git a/tests/test_ui_export_dialog.py b/tests/test_ui_export_dialog.py index 440189d91..c6307986c 100644 --- a/tests/test_ui_export_dialog.py +++ b/tests/test_ui_export_dialog.py @@ -24,6 +24,8 @@ from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker from agentgrep.ui.widgets.export_pane import ExportDraft, ExportIntent +pytestmark = pytest.mark.tui + _TIMESTAMP = datetime.datetime(2026, 7, 14, 9, 8, 7, tzinfo=datetime.UTC) @@ -185,6 +187,7 @@ def test_pane_binding_priorities_preserve_focused_controls() -> None: assert all(bindings[key].priority is False for key in ("up", "down")) +@pytest.mark.slow async def test_preview_is_frozen_literal_and_uses_no_filesystem( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -214,6 +217,7 @@ def unexpected_filesystem(*_args: object, **_kwargs: object) -> t.NoReturn: assert _text(app, "#export-preview") == ("2026-07-14-09-08-07-machine-readable-title.md") +@pytest.mark.slow async def test_enter_moves_directory_to_template(tmp_path: pathlib.Path) -> None: """Enter in the directory field advances to the filename editor.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -239,6 +243,7 @@ async def test_enter_moves_directory_to_template(tmp_path: pathlib.Path) -> None ("down", "directory", "template"), ], ) +@pytest.mark.slow async def test_directional_keys_traverse_and_clamp_without_editing( key: str, start: str, @@ -270,6 +275,7 @@ async def test_directional_keys_traverse_and_clamp_without_editing( assert _dialog(app).phase == "edit" +@pytest.mark.slow async def test_left_right_remain_native_template_cursor_keys(tmp_path: pathlib.Path) -> None: """Bare horizontal arrows edit the cursor instead of traversing fields.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -288,6 +294,7 @@ async def test_left_right_remain_native_template_cursor_keys(tmp_path: pathlib.P assert template.cursor_position == 2 +@pytest.mark.slow async def test_directory_input_receives_n_and_y(tmp_path: pathlib.Path) -> None: """Review shortcut letters remain ordinary text in the directory editor.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -302,6 +309,7 @@ async def test_directory_input_receives_n_and_y(tmp_path: pathlib.Path) -> None: assert _dialog(app).phase == "edit" +@pytest.mark.slow async def test_template_input_receives_n_and_y(tmp_path: pathlib.Path) -> None: """Review shortcut letters remain ordinary text in the template editor.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -316,6 +324,7 @@ async def test_template_input_receives_n_and_y(tmp_path: pathlib.Path) -> None: assert _dialog(app).phase == "edit" +@pytest.mark.slow async def test_invalid_template_stays_edit_with_path_free_error( tmp_path: pathlib.Path, ) -> None: @@ -336,6 +345,7 @@ async def test_invalid_template_stays_edit_with_path_free_error( assert seen == [] +@pytest.mark.slow async def test_validation_runs_off_pump( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -358,6 +368,7 @@ def observed_access(path: os.PathLike[str], mode: int) -> bool: @pytest.mark.parametrize("cancel_key", ["n", "ctrl+c"], ids=("no", "cancel")) +@pytest.mark.slow async def test_first_use_default_review_does_not_create_directory( cancel_key: str, tmp_path: pathlib.Path, @@ -388,6 +399,7 @@ async def test_first_use_default_review_does_not_create_directory( assert not directory.exists() +@pytest.mark.slow async def test_home_default_is_reviewed_as_tilde( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -412,6 +424,7 @@ async def test_home_default_is_reviewed_as_tilde( assert str(home) not in _text(app, "#export-review-directory") +@pytest.mark.slow async def test_directory_outside_home_remains_literal(tmp_path: pathlib.Path) -> None: """A selected directory outside the session home keeps its exact draft text.""" home = tmp_path / "home" @@ -432,6 +445,7 @@ async def test_directory_outside_home_remains_literal(tmp_path: pathlib.Path) -> assert _text(app, "#export-review-directory") == str(directory) +@pytest.mark.slow async def test_submitted_absolute_home_directory_is_compacted( tmp_path: pathlib.Path, ) -> None: @@ -450,6 +464,7 @@ async def test_submitted_absolute_home_directory_is_compacted( assert _text(app, "#export-review-directory") == "~/Exports" +@pytest.mark.slow async def test_empty_directory_cannot_reach_review(tmp_path: pathlib.Path) -> None: """A cleared directory stays in edit with a path-free validation error.""" seen: list[ExportIntent] = [] @@ -466,6 +481,7 @@ async def test_empty_directory_cannot_reach_review(tmp_path: pathlib.Path) -> No assert seen == [] +@pytest.mark.slow async def test_over_bound_directory_stops_before_compaction( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -491,6 +507,7 @@ def fail_compaction(_value: str, _home: pathlib.Path) -> t.NoReturn: assert seen == [] +@pytest.mark.slow async def test_missing_arbitrary_directory_is_not_created(tmp_path: pathlib.Path) -> None: """Validation never creates a missing user-entered directory tree.""" directory = tmp_path / "missing" / "arbitrary" @@ -508,6 +525,7 @@ async def test_missing_arbitrary_directory_is_not_created(tmp_path: pathlib.Path assert not directory.exists() +@pytest.mark.slow async def test_existing_bidi_directory_is_rejected(tmp_path: pathlib.Path) -> None: """An existing path with unreviewable format controls cannot reach review.""" home = tmp_path / "home" @@ -522,6 +540,7 @@ async def test_existing_bidi_directory_is_rejected(tmp_path: pathlib.Path) -> No assert _text(app, "#export-error") == "Export directory is invalid" +@pytest.mark.slow async def test_default_directory_creation_rejects_symlinked_app_path( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -552,6 +571,7 @@ async def test_default_directory_creation_rejects_symlinked_app_path( assert {entry.name for entry in outside.iterdir()} == {"keep.txt"} +@pytest.mark.slow async def test_existing_exact_destination_prevents_review(tmp_path: pathlib.Path) -> None: """Validation refuses the exact previewed basename instead of clobbering it.""" destination = tmp_path / "2026-07-14 09-08-07 - machine-readable-title.md" @@ -566,6 +586,7 @@ async def test_existing_exact_destination_prevents_review(tmp_path: pathlib.Path assert app.screen.query_one("#export-template", Input).has_focus +@pytest.mark.slow async def test_review_shows_directory_and_filename_literally(tmp_path: pathlib.Path) -> None: """Review renders user-controlled brackets as text, never as markup.""" directory = tmp_path / "exports-[literal]" @@ -586,6 +607,7 @@ async def test_review_shows_directory_and_filename_literally(tmp_path: pathlib.P assert confirm.highlighted == 0 +@pytest.mark.slow async def test_review_uses_compact_pi_confirmation_layout(tmp_path: pathlib.Path) -> None: """Review presents one quiet question, compact choices, and a fixed hint.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -609,6 +631,7 @@ async def test_review_uses_compact_pi_confirmation_layout(tmp_path: pathlib.Path assert _text(app, "#export-review-status") == ("↑↓ move · Enter · Esc edit") +@pytest.mark.slow async def test_review_up_down_still_select_confirmation_rows(tmp_path: pathlib.Path) -> None: """Edit-stage traversal leaves review-list arrows unchanged.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -631,6 +654,7 @@ async def test_review_up_down_still_select_confirmation_rows(tmp_path: pathlib.P assert _dialog(app).phase == "review" +@pytest.mark.slow async def test_no_returns_to_editor_without_losing_values( tmp_path: pathlib.Path, ) -> None: @@ -650,6 +674,7 @@ async def test_no_returns_to_editor_without_losing_values( assert seen == [] +@pytest.mark.slow async def test_repeated_enter_on_default_no_cannot_save(tmp_path: pathlib.Path) -> None: """Repeated Enter alternates review and edit without selecting Save.""" seen: list[ExportIntent] = [] @@ -665,6 +690,7 @@ async def test_repeated_enter_on_default_no_cannot_save(tmp_path: pathlib.Path) @pytest.mark.parametrize("key", ["n", "escape"]) +@pytest.mark.slow async def test_no_shortcuts_return_to_edit(tmp_path: pathlib.Path, key: str) -> None: """The explicit No gestures preserve the draft and prior focus.""" seen: list[ExportIntent] = [] @@ -678,6 +704,7 @@ async def test_no_shortcuts_return_to_edit(tmp_path: pathlib.Path, key: str) -> assert seen == [] +@pytest.mark.slow async def test_y_invokes_once_and_enters_saving(tmp_path: pathlib.Path) -> None: """Save delegates once and disables every further write gesture.""" seen: list[ExportIntent] = [] @@ -706,6 +733,7 @@ async def test_y_invokes_once_and_enters_saving(tmp_path: pathlib.Path) -> None: @pytest.mark.parametrize("key", ["escape", "ctrl+c"]) +@pytest.mark.slow async def test_saving_ignores_cancel_keys(tmp_path: pathlib.Path, key: str) -> None: """A delegated durable write keeps its modal until worker completion.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -767,6 +795,7 @@ async def test_ctrl_c_clears_focused_edit_before_dismissal( assert not app.query(ExportPane) +@pytest.mark.slow async def test_escape_dismisses_from_edit(tmp_path: pathlib.Path) -> None: """Escape cancels the dialog outside the review back-step.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -777,6 +806,7 @@ async def test_escape_dismisses_from_edit(tmp_path: pathlib.Path) -> None: assert not app.query(ExportPane) +@pytest.mark.slow async def test_export_failed_restores_edit_with_values(tmp_path: pathlib.Path) -> None: """An asynchronous write error returns to the retained draft.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -795,6 +825,7 @@ async def test_export_failed_restores_edit_with_values(tmp_path: pathlib.Path) - assert app.screen.query_one("#export-template", Input).has_focus +@pytest.mark.slow async def test_export_failed_keeps_error_visible_in_small_terminal( tmp_path: pathlib.Path, ) -> None: @@ -819,6 +850,7 @@ async def test_export_failed_keeps_error_visible_in_small_terminal( assert error.region.bottom <= 10 +@pytest.mark.slow async def test_pending_error_reveal_ignores_rapid_dismiss( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -839,6 +871,7 @@ async def test_pending_error_reveal_ignores_rapid_dismiss( assert observations == [True] +@pytest.mark.slow async def test_pending_error_reveal_ignores_cleared_error( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -860,6 +893,7 @@ async def test_pending_error_reveal_ignores_cleared_error( assert observations == [True] +@pytest.mark.slow async def test_export_succeeded_dismisses(tmp_path: pathlib.Path) -> None: """An asynchronous write success closes the retained saving modal.""" app = _ExportDialogHost(tmp_path, lambda _intent: True) @@ -872,6 +906,7 @@ async def test_export_succeeded_dismisses(tmp_path: pathlib.Path) -> None: assert not app.query(ExportPane) +@pytest.mark.slow async def test_pane_fits_compact_terminal_without_horizontal_scroll( tmp_path: pathlib.Path, ) -> None: @@ -892,6 +927,7 @@ async def test_pane_fits_compact_terminal_without_horizontal_scroll( @pytest.mark.parametrize("size", [(60, 16), (30, 10)]) +@pytest.mark.slow async def test_edit_footer_is_docked_without_copy_change( tmp_path: pathlib.Path, size: tuple[int, int], @@ -911,6 +947,7 @@ async def test_edit_footer_is_docked_without_copy_change( @pytest.mark.parametrize("size", [(40, 12), (30, 10)]) +@pytest.mark.slow async def test_invalid_template_error_is_visible_in_small_terminal( size: tuple[int, int], tmp_path: pathlib.Path, @@ -935,6 +972,7 @@ async def test_invalid_template_error_is_visible_in_small_terminal( @pytest.mark.parametrize("size", [(40, 12), (30, 10)]) +@pytest.mark.slow async def test_review_and_edit_are_reachable_in_small_terminal( size: tuple[int, int], tmp_path: pathlib.Path, diff --git a/tests/test_ui_export_directory_popup.py b/tests/test_ui_export_directory_popup.py index bb60d173e..0ffff845f 100644 --- a/tests/test_ui_export_directory_popup.py +++ b/tests/test_ui_export_directory_popup.py @@ -26,6 +26,8 @@ ExportDirectoryPicker, ) +pytestmark = pytest.mark.tui + class _DirectoryPopupHost(App[None]): """Minimal export-dialog edit stage for Pilot interaction tests.""" @@ -122,6 +124,7 @@ def fail_scandir(_path: os.PathLike[str]) -> t.NoReturn: assert result.truncated is False +@pytest.mark.slow async def test_directory_enumeration_waits_for_inactivity( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -164,6 +167,7 @@ def observed( assert calls[0][1] - changed_at >= DIRECTORY_COMPLETION_DEBOUNCE - 0.02 +@pytest.mark.slow async def test_directory_enumeration_coalesces_while_worker_is_blocked( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -409,6 +413,7 @@ def test_completion_omits_unreviewable_directory_names( ) +@pytest.mark.slow async def test_popup_is_literal_bounded_off_pump_and_reports_truncation( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -443,6 +448,7 @@ def observed_scandir(path: str | os.PathLike[str]) -> t.Any: assert scan_threads and all(thread_id != pump_thread for thread_id in scan_threads) +@pytest.mark.slow async def test_up_down_wrap_and_right_accepts_only_at_end(tmp_path: pathlib.Path) -> None: """Navigation wraps while mid-string Right retains native cursor movement.""" root = tmp_path / "choices" @@ -475,6 +481,7 @@ async def test_up_down_wrap_and_right_accepts_only_at_end(tmp_path: pathlib.Path assert field.has_focus +@pytest.mark.slow async def test_tab_accepts_only_when_open_then_traverses(tmp_path: pathlib.Path) -> None: """Tab accepts one visible row, then resumes normal focus traversal.""" root = tmp_path / "choices" @@ -496,6 +503,7 @@ async def test_tab_accepts_only_when_open_then_traverses(tmp_path: pathlib.Path) assert filename.has_focus +@pytest.mark.slow async def test_late_directory_result_cannot_reopen_after_tab( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -536,6 +544,7 @@ def delayed( assert app.query_one("#filename", Input).has_focus +@pytest.mark.slow async def test_unmount_cancels_worker_and_invalidates_generation( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, @@ -584,6 +593,7 @@ def delayed( assert not app.query(ExportDirectoryPicker) +@pytest.mark.slow async def test_popup_stays_within_picker_at_compact_geometry(tmp_path: pathlib.Path) -> None: """The borderless overlay never exceeds its owning picker at 60 by 16.""" (tmp_path / "alpha").mkdir() diff --git a/tests/test_ui_export_pane.py b/tests/test_ui_export_pane.py index f77be3818..8b3fe0dbd 100644 --- a/tests/test_ui_export_pane.py +++ b/tests/test_ui_export_pane.py @@ -13,7 +13,7 @@ from agentgrep.ui import widgets from agentgrep.ui.widgets.directory_popup import ExportDirectoryPicker -from tests.test_agentgrep_tui_identity import _build_empty_ui_app +from tests._tui_export_support import _build_empty_ui_app from tests.test_ui_export import _load_records, _record, _static_text, _wait_for pytestmark = pytest.mark.tui @@ -262,9 +262,7 @@ async def test_export_pane_saves_frozen_record_and_is_fresh_next_time( hud = app.screen await _load_records(hud, records, selected=0) later_index = next( - index - for index, record in enumerate(hud.filtered_records) - if record is records[1] + index for index, record in enumerate(hud.filtered_records) if record is records[1] ) hud._results.focus() await pilot.press("e") diff --git a/tests/test_ui_export_preferences.py b/tests/test_ui_export_preferences.py index 392e1771e..2ebe6e192 100644 --- a/tests/test_ui_export_preferences.py +++ b/tests/test_ui_export_preferences.py @@ -30,6 +30,8 @@ save_export_preferences, ) +pytestmark = pytest.mark.tui + DEFAULT_TEMPLATE = "{date} {time} - {title}.md"