From bbc3ee1bfa7150962ab20ab3727f9bf035692ac9 Mon Sep 17 00:00:00 2001 From: Geoff Hackett Date: Mon, 3 Aug 2026 10:09:23 -0400 Subject: [PATCH 1/2] feat(terminal): bare root-level filenames are clickable links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path grammar demands a slash, so root-level references (README.md, pyproject.toml) never underlined. A shape can't take them — bare-filename shapes over-match prose — but an alternation of the names actually sitting at the project root can: the underline then only ever lands on a name that exists. bare_names_pattern builds that grammar (same boundary discipline and :line[:col] suffix as FILE_PATTERN, longest name first so an entry extending another wins), and _RootNameLinks keeps it registered per terminal — built on first map from the tab's link_root (the directory the editor opens at), rebuilt via a debounced directory monitor when the root's name set changes. Files only: root directory names are everyday prose words, and `docs/` already belongs to the path grammar. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KBnWBFL3yHBnJy4kupKZgk --- collins/linkpatterns.py | 35 +++++++++++++ collins/terminal.py | 103 ++++++++++++++++++++++++++++++++++++- tests/test_linkpatterns.py | 87 +++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) diff --git a/collins/linkpatterns.py b/collins/linkpatterns.py index 18fe22e..98a794c 100644 --- a/collins/linkpatterns.py +++ b/collins/linkpatterns.py @@ -13,6 +13,10 @@ ignored — the click falls through to the terminal. Bare filenames (``terminal.py``) are deliberately out of the grammar; without a slash the false-positive rate in ordinary prose is too high. +- ``bare_names_pattern``: the one sound way back in for bare filenames — an + alternation of *known* names (the entries actually sitting at a tab's + project root), so the hover underline can only ever land on a name that + exists. Built per terminal at runtime; see terminal._RootNameLinks. Both grammars stop at whitespace, quotes and brackets, and refuse to *end* on punctuation that prose tends to hang off a reference — so @@ -25,6 +29,7 @@ import os import re +from collections.abc import Iterable # One body-then-final-char pair per alternative: the greedy body backtracks # until the last character is something a URL can plausibly end on. @@ -58,6 +63,36 @@ f"{_LINE_SUFFIX}" ) +# Names the bare grammar can't take: whitespace can't be bounded by a regex +# at all, `:` would collide with the line suffix, and `/` belongs to the +# slashed grammar above. +_BARE_UNBOUNDABLE = re.compile("[\\s:/]") + + +def bare_names_pattern(names: Iterable[str]) -> str | None: + """A FILE_PATTERN companion matching any of *names* as a bare token. + + Bare filenames stay out of FILE_PATTERN because a *shape* can't help + over-matching prose — but an alternation of literal names underlines + only what genuinely exists, so the usual objection disappears. Same + boundary discipline as paths: a boundary (or line start) before, the + optional ``:line[:col]`` suffix after, and the token must not continue + with a character a path could end on — ``README.md.`` sheds its period + while ``README.mdx`` never half-matches an entry ``README.md``. Longest + name first, so an entry extending another wins the alternation + (``README.md.bak`` before ``README.md``). None when nothing survives + the unboundable-name filter: no regex to register at all. + """ + usable = sorted( + {name for name in names if name and not _BARE_UNBOUNDABLE.search(name)}, + key=lambda name: (-len(name), name), + ) + if not usable: + return None + alternatives = "|".join(re.escape(name) for name in usable) + return f"{_PATH_PRE}(?:{alternatives}){_LINE_SUFFIX}(?!{_PATH_FINAL})" + + _SUFFIX = re.compile(r"(.+?):(\d+)(?::(\d+))?") _FILE_RX = re.compile(FILE_PATTERN) diff --git a/collins/terminal.py b/collins/terminal.py index d1c04d9..cb99074 100644 --- a/collins/terminal.py +++ b/collins/terminal.py @@ -44,6 +44,7 @@ from .linkpatterns import ( # noqa: E402 FILE_PATTERN, URL_PATTERN, + bare_names_pattern, resolve_file_reference, resolve_wrapped_reference, ) @@ -154,7 +155,9 @@ def _setup_links(terminal: Vte.Terminal) -> None: filesystem only at click time — VTE has no per-match callback, so the hover underline can't know whether the file exists, but a click on a candidate that resolves nowhere falls through to the terminal unclaimed - and costs the user nothing. + and costs the user nothing. Root-level files carry no slash, so the path + grammar can't take them; _RootNameLinks keeps one more regex built from + the names actually at the project root, so `README.md` underlines too. """ terminal.set_allow_hyperlink(True) tag_kinds: dict[int, str] = {} @@ -168,6 +171,7 @@ def _setup_links(terminal: Vte.Terminal) -> None: tag = terminal.match_add_regex(regex, 0) terminal.match_set_cursor_name(tag, "pointer") tag_kinds[tag] = kind + _RootNameLinks(terminal, tag_kinds) def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: if not gesture.get_current_event_state() & Gdk.ModifierType.CONTROL_MASK: @@ -282,6 +286,99 @@ def row_text(r: int) -> str: return None +class _RootNameLinks: + """_setup_links' bare-filename grammar: one extra match tag holding an + alternation of the names actually sitting at the tab's project root + (bare_names_pattern), so `README.md` underlines without the slash the + path grammar demands. Files only — root directory names (docs, tests) + are everyday prose words, and `docs/` is already the path grammar's. + + The root lives on the ancestor TerminalTab, which isn't an ancestor yet + while either terminal kind is being constructed — so the tag is built on + first map and the root never re-resolved (a tab's root is fixed for + life; panel bottom↔right swaps re-map without reparenting tabs). A + directory monitor keeps the alternation honest: on changes the names + are re-listed (debounced — a busy agent touches root files in bursts, + and most events are content writes that leave the name set alone) and + the tag swapped only when the set really changed. Between snapshots the + usual guarantees hold: a deleted file's click resolves nowhere and falls + through unclaimed. The terminal's signal closures keep the instance + alive; no one else needs to hold it.""" + + def __init__(self, terminal: Vte.Terminal, tag_kinds: dict[int, str]) -> None: + self._terminal = terminal + self._tag_kinds = tag_kinds + self._root: str | None = None + self._names: frozenset[str] | None = None + self._tag: int | None = None + self._monitor: Gio.FileMonitor | None = None + self._refresh_source: int | None = None + terminal.connect("map", self._on_map) + terminal.connect("destroy", self._on_destroy) + + def _on_map(self, _terminal: Vte.Terminal) -> None: + if self._root is not None: + return # re-mapped (panel swap); the tag and monitor live on + tab = self._terminal.get_ancestor(TerminalTab) + if tab is None: + return + self._root = tab.link_root + self._apply() + try: + self._monitor = Gio.File.new_for_path(self._root).monitor_directory( + Gio.FileMonitorFlags.NONE, None + ) + except GLib.Error: + return # no monitor backend: the map-time snapshot still serves + self._monitor.connect("changed", self._on_root_changed) + + def _on_root_changed(self, *_args) -> None: + if self._refresh_source is None: + self._refresh_source = GLib.timeout_add(500, self._refresh) + + def _refresh(self) -> bool: + self._refresh_source = None + self._apply() + return GLib.SOURCE_REMOVE + + def _apply(self) -> None: + names = self._file_names() + if names == self._names: + return + self._names = names + if self._tag is not None: + self._terminal.match_remove(self._tag) + del self._tag_kinds[self._tag] + self._tag = None + pattern = bare_names_pattern(names) + if pattern is None: + return + try: + regex = Vte.Regex.new_for_match(pattern, len(pattern.encode()), _PCRE2_MULTILINE) + except GLib.Error: + return # VTE built without PCRE2 (the static tags are gone too) + self._tag = self._terminal.match_add_regex(regex, 0) + self._terminal.match_set_cursor_name(self._tag, "pointer") + self._tag_kinds[self._tag] = "file" + + def _file_names(self) -> frozenset[str]: + try: + with os.scandir(self._root) as entries: + # is_dir follows symlinks, so a directory behind a link is + # excluded the same way a plain one is. + return frozenset(entry.name for entry in entries if not entry.is_dir()) + except OSError: + return frozenset() + + def _on_destroy(self, _terminal: Vte.Terminal) -> None: + if self._monitor is not None: + self._monitor.cancel() + self._monitor = None + if self._refresh_source is not None: + GLib.source_remove(self._refresh_source) + self._refresh_source = None + + def _open_file_reference( terminal: Vte.Terminal, path: str, line: int | None, col: int | None ) -> None: @@ -1027,6 +1124,10 @@ def __init__( # a construct-on-demand race; HAVE_GTKSOURCE false leaves it None and # the footer button that would open it hidden (see _build_footer). editor_root = cwd if cwd and Path(cwd).is_dir() else str(Path.home()) + # Where bare root-name links look (_RootNameLinks): the directory the + # editor opens at, kept even when GtkSourceView (and so the editor + # itself, and the editor_root property) is missing. + self.link_root: str = editor_root self._editor = editor.EditorPane(editor_root) if editor.HAVE_GTKSOURCE else None self._editor_detached = False # pane reparented into its own EditorWindow self._editor_width = 0 # this tab's last-set editor width, px (0 = none yet) diff --git a/tests/test_linkpatterns.py b/tests/test_linkpatterns.py index b942572..3e5ce6a 100644 --- a/tests/test_linkpatterns.py +++ b/tests/test_linkpatterns.py @@ -12,6 +12,7 @@ from collins.linkpatterns import ( FILE_PATTERN, URL_PATTERN, + bare_names_pattern, resolve_file_reference, resolve_wrapped_reference, ) @@ -149,6 +150,92 @@ def test_urls_still_match_as_urls() -> None: assert _first_match(text) == text +# -- bare_names_pattern ------------------------------------------------------ + +_ROOT_NAMES = ["README.md", "pyproject.toml", "LICENSE", ".gitignore", "start-debug"] + + +def _first_bare_match(text: str, names: list[str] = _ROOT_NAMES) -> str | None: + pattern = bare_names_pattern(names) + assert pattern is not None + m = re.search(pattern, text) + return m.group(0) if m else None + + +@pytest.mark.parametrize( + "text,expected", + [ + ("open README.md please", "README.md"), + # At the very start of a line, like FILE_PATTERN's lookbehind. + ("README.md changed", "README.md"), + ("at README.md:12 there", "README.md:12"), + ("at README.md:12:5 there", "README.md:12:5"), + ("see `pyproject.toml` for deps", "pyproject.toml"), + ("read (LICENSE) first", "LICENSE"), + ("a hidden .gitignore too", ".gitignore"), + ("run start-debug now", "start-debug"), + ], +) +def test_bare_names_match(text: str, expected: str) -> None: + assert _first_bare_match(text) == expected + + +@pytest.mark.parametrize( + "text,expected", + [ + # The same ending discipline as the other grammars. + ("update the README.md.", "README.md"), + ("fixed README.md:12.", "README.md:12"), + ("README.md, then LICENSE", "README.md"), + ("is it README.md?", "README.md"), + ("quote 'LICENSE' end", "LICENSE"), + ], +) +def test_bare_name_sheds_trailing_punctuation(text: str, expected: str) -> None: + assert _first_bare_match(text) == expected + + +@pytest.mark.parametrize( + "text", + [ + # A known name must never half-match inside a longer token, in + # either direction. + "a README.mdx variant", + "xREADME.md glued on", + "the README.md-old backup", + # Slashed references are the path grammar's territory; the bare + # grammar must stay out (the lookbehind sees the slash). + "docs/README.md is a path", + "no names here at all", + ], +) +def test_bare_name_boundaries(text: str) -> None: + assert _first_bare_match(text) is None + + +def test_longer_entry_wins_over_its_prefix() -> None: + # Sorting inside bare_names_pattern, not caller order, decides: the + # alternation must try README.md.bak before README.md either way. + for names in (["README.md", "README.md.bak"], ["README.md.bak", "README.md"]): + assert _first_bare_match("see README.md.bak", names) == "README.md.bak" + + +def test_names_with_regex_metacharacters_match_literally() -> None: + assert _first_bare_match("open note[1]+x.md now", ["note[1]+x.md"]) == "note[1]+x.md" + # Unescaped, `[1]+` would read as a repeated class and admit this text; + # the literal must not. + assert _first_bare_match("open note11x.md now", ["note[1]+x.md"]) is None + + +def test_unboundable_names_are_filtered() -> None: + # Whitespace can't be bounded, `:` collides with the line suffix, `/` + # belongs to the path grammar; nothing usable means no pattern at all. + assert bare_names_pattern([]) is None + assert bare_names_pattern(["with space.txt", "a:b", "a/b", ""]) is None + # ...and unusable names don't poison the usable rest. + assert _first_bare_match("see README.md", ["with space.txt", "README.md"]) == "README.md" + + # -- resolve_file_reference ------------------------------------------------ From 7c66860d203f3dbb260c326979a12847a409d654 Mon Sep 17 00:00:00 2001 From: Geoff Hackett Date: Wed, 5 Aug 2026 13:43:46 -0400 Subject: [PATCH 2/2] review: commit the wiring check, fix throttle wording, document HOME fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR 177 review: the _RootNameLinks wiring check now lives at scripts/check_root_name_links.py (a script, not a pytest test — conftest blocks the GTK namespaces suite-wide so local runs reproduce CI); the class docstring now calls the 500ms coalescing what it is, a leading-edge throttle chosen so steady churn can't starve the refresh; and link_root's comment spells out that inheriting editor_root's HOME fallback is deliberate — the whole tab is rooted at home in that case, so bare names follow the editor and quick open rather than going dark. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KBnWBFL3yHBnJy4kupKZgk --- collins/terminal.py | 18 ++++-- scripts/check_root_name_links.py | 100 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100755 scripts/check_root_name_links.py diff --git a/collins/terminal.py b/collins/terminal.py index cb99074..78927ac 100644 --- a/collins/terminal.py +++ b/collins/terminal.py @@ -1,6 +1,6 @@ # Modified from the original agent-session-manager # (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett -# fork. Last modified: 2026-08-03. Full change history: git log for this file. +# fork. Last modified: 2026-08-05. Full change history: git log for this file. """A tab hosting a VTE terminal running the user's shell with an agent CLI inside.""" @@ -298,9 +298,13 @@ class _RootNameLinks: first map and the root never re-resolved (a tab's root is fixed for life; panel bottom↔right swaps re-map without reparenting tabs). A directory monitor keeps the alternation honest: on changes the names - are re-listed (debounced — a busy agent touches root files in bursts, - and most events are content writes that leave the name set alone) and - the tag swapped only when the set really changed. Between snapshots the + are re-listed and the tag swapped only when the set really changed. + Change events coalesce on a 500ms timer armed by the first one — a + leading-edge throttle, deliberately not a trailing-edge debounce, so an + agent churning root files steadily can't starve the refresh; a rebuild + that lands mid-burst is harmless (the next event re-arms the timer, and + most events are content writes that leave the set alone anyway). + Between snapshots the usual guarantees hold: a deleted file's click resolves nowhere and falls through unclaimed. The terminal's signal closures keep the instance alive; no one else needs to hold it.""" @@ -1126,7 +1130,11 @@ def __init__( editor_root = cwd if cwd and Path(cwd).is_dir() else str(Path.home()) # Where bare root-name links look (_RootNameLinks): the directory the # editor opens at, kept even when GtkSourceView (and so the editor - # itself, and the editor_root property) is missing. + # itself, and the editor_root property) is missing. That includes the + # HOME fallback above, deliberately: when a project dir is gone this + # whole tab is already rooted at home — the editor, quick open, and + # click-time resolution — so bare names follow suit rather than + # becoming the one link kind that goes dark. self.link_root: str = editor_root self._editor = editor.EditorPane(editor_root) if editor.HAVE_GTKSOURCE else None self._editor_detached = False # pane reparented into its own EditorWindow diff --git a/scripts/check_root_name_links.py b/scripts/check_root_name_links.py new file mode 100755 index 0000000..7b1ec15 --- /dev/null +++ b/scripts/check_root_name_links.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Wiring check for terminal._RootNameLinks — run on a dev machine. + +Exercises the stateful side of bare root-name links that the unit tests in +tests/test_linkpatterns.py can't reach: map-time root resolution through a +real TerminalTab, match-tag registration, the monitor-driven rebuild when +the root's name set changes (and *only* then), and teardown on destroy. + +This is a script, not a pytest test, on purpose: tests/conftest.py blocks +the GTK-stack namespaces for the whole suite so local runs reproduce CI +(which installs python3-gi only — no gir packages, no display). Testing +widgets for real means running this by hand: + + python3 scripts/check_root_name_links.py + +No window is ever shown; the tab is driven unrealized and the child command +is `true`, so no agent CLI launches either. +""" + +import gc +import os +import sys +import tempfile +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +gi.require_version("Vte", "3.91") +from gi.repository import GLib # noqa: E402 + +import collins.terminal as terminal_mod # noqa: E402 + + +def _pump(ctx: GLib.MainContext, seconds: float, until=lambda: False) -> None: + deadline = time.time() + seconds + while time.time() < deadline and not until(): + ctx.iteration(False) + time.sleep(0.02) + + +def main() -> int: + root = tempfile.mkdtemp(prefix="rootlinks-check-") + for name in ("README.md", "notes.txt", "with space.txt"): + open(os.path.join(root, name), "w").close() + os.mkdir(os.path.join(root, "docs")) + + tab = terminal_mod.TerminalTab(cwd=root, command_override="true") + assert tab.link_root == root, tab.link_root + + matchers = [ + m + for m in gc.get_objects() + if isinstance(m, terminal_mod._RootNameLinks) and m._terminal is tab.terminal + ] + assert len(matchers) == 1, f"expected 1 matcher for the tab terminal, got {len(matchers)}" + matcher = matchers[0] + + # The tab is the terminal's ancestor already (no realization needed): + # drive the map handler the way GTK would. + matcher._on_map(tab.terminal) + assert matcher._root == root, matcher._root + # Directories excluded; the space name is listed here and filtered later + # by bare_names_pattern (a name-set change to it still means a rebuild). + assert matcher._names == {"README.md", "notes.txt", "with space.txt"}, matcher._names + assert matcher._tag is not None + assert matcher._tag_kinds[matcher._tag] == "file" + assert matcher._monitor is not None + first_tag = matcher._tag + print(f"map wiring OK: tag {first_tag}, names {sorted(matcher._names)}") + + # A new root file must swap the tag for a rebuilt one... + ctx = GLib.MainContext.default() + open(os.path.join(root, "CHANGELOG.md"), "w").close() + _pump(ctx, 5, until=lambda: "CHANGELOG.md" in (matcher._names or ())) + assert "CHANGELOG.md" in matcher._names, matcher._names + assert matcher._tag is not None and matcher._tag != first_tag + assert first_tag not in matcher._tag_kinds + assert matcher._tag_kinds[matcher._tag] == "file" + print(f"monitor rebuild OK: tag {first_tag} -> {matcher._tag}") + + # ...while a content-only write leaves the name set, and so the tag, alone. + second_tag = matcher._tag + with open(os.path.join(root, "README.md"), "w") as f: + f.write("content only\n") + _pump(ctx, 1.5) + assert matcher._tag == second_tag, "content-only write must not swap the tag" + print("content-only write left the tag alone") + + matcher._on_destroy(tab.terminal) + assert matcher._monitor is None and matcher._refresh_source is None + print("teardown OK — ALL WIRING CHECKS PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main())