From ea6cebcc8e3be6f38cb68dd536a76c948349e941 Mon Sep 17 00:00:00 2001 From: Geoff Hackett Date: Mon, 3 Aug 2026 09:29:35 -0400 Subject: [PATCH] feat(terminal): stitch hard-wrapped file references at click time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the CLI hard-wraps a long path (a real newline plus continuation indent), the screen holds two separate fragments and no regex can match across them. Clicking either fragment now stitches: geometry-gated (the fragment must touch its row's edge — the signature of a wrapped token), joined with the adjacent token of neighbour rows (chaining across whole-row middle fragments, 2 up / 3 down), re-matched against FILE_PATTERN, and existence-checked like any other candidate. Stitching runs before direct resolution: the leading fragment of a wrapped path is often an existing directory prefix, and the stitched whole is the truer reading. Verified live: both fragments of a hard-wrapped absolute path open the full file (the head fragment alone resolves to .claude/worktrees — the ordering hazard), a mid-row bogus candidate stays inert, and the y-to-row math holds under pixel scrolling with a scrolled-back buffer. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Dpjh2MJkFJNTjUbbZm1tSN --- collins/linkpatterns.py | 65 +++++++++++++++++++++++++++++ collins/terminal.py | 60 ++++++++++++++++++++++++++- tests/test_linkpatterns.py | 84 +++++++++++++++++++++++++++++++++++++- 3 files changed, 206 insertions(+), 3 deletions(-) diff --git a/collins/linkpatterns.py b/collins/linkpatterns.py index 75651ef..18fe22e 100644 --- a/collins/linkpatterns.py +++ b/collins/linkpatterns.py @@ -59,6 +59,13 @@ ) _SUFFIX = re.compile(r"(.+?):(\d+)(?::(\d+))?") +_FILE_RX = re.compile(FILE_PATTERN) + +# How many rows a hard-wrapped reference may be stitched across, per +# direction. Agent CLIs wrap long paths over two rows, occasionally three; +# beyond that the joins are more likely to be accidental than real. +_STITCH_ROWS_UP = 2 +_STITCH_ROWS_DOWN = 3 def resolve_file_reference( @@ -90,3 +97,61 @@ def resolve_file_reference( if os.path.exists(trial): return os.path.normpath(trial), line, col return None + + +def resolve_wrapped_reference( + candidate: str, + row_text: str, + rows_above: list[str], + rows_below: list[str], + roots: list[str | None], +) -> tuple[str, int | None, int | None] | None: + """A candidate that resolved nowhere may be a fragment of a reference the + *emitter* hard-wrapped — a real newline plus continuation indent in the + output, which no regex over screen text can see past. + + The stitch is geometry-gated: fragments are only joined downward when the + candidate sits at the very end of its row, and upward when it sits at the + start (after indent) — the two signatures of a wrapped token. Neighbour + rows contribute their adjacent whitespace-delimited token, chaining + further only while a whole row was one token (a middle fragment). + *rows_above*/*rows_below* are nearest-first. Every join is re-matched + against FILE_PATTERN and then existence-checked like any other candidate, + so a stitch that guesses wrong still opens nothing. + """ + row = row_text.rstrip("\n") + downs = [""] + if row.rstrip().endswith(candidate): + chain = "" + for below in rows_below[:_STITCH_ROWS_DOWN]: + frag = below.strip() + if not frag: + break + token = frag.split()[0] + chain += token + downs.append(chain) + if token != frag: + break + ups = [""] + if row.lstrip().startswith(candidate): + chain = "" + for above in rows_above[:_STITCH_ROWS_UP]: + frag = above.strip() + if not frag: + break + token = frag.split()[-1] + chain = token + chain + ups.append(chain) + if token != frag: + break + for up in reversed(ups): # longest joins first + for down in reversed(downs): + if not up and not down: + continue # the bare candidate already failed + m = _FILE_RX.match(up + candidate + down) + if m is None: + continue + resolved = resolve_file_reference(m.group(0), roots) + if resolved is not None: + return resolved + return None diff --git a/collins/terminal.py b/collins/terminal.py index 45a77a5..d1c04d9 100644 --- a/collins/terminal.py +++ b/collins/terminal.py @@ -41,7 +41,12 @@ from .gitinfo import current_branch, has_changes # noqa: E402 from .i18n import _, ngettext # noqa: E402 from .lightbox import present_image_lightbox # noqa: E402 -from .linkpatterns import FILE_PATTERN, URL_PATTERN, resolve_file_reference # noqa: E402 +from .linkpatterns import ( # noqa: E402 + FILE_PATTERN, + URL_PATTERN, + resolve_file_reference, + resolve_wrapped_reference, +) from .promptcard import build_question_card # noqa: E402 from .providers import Provider, get_provider # noqa: E402 from .prstatus import ( # noqa: E402 @@ -179,7 +184,16 @@ def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: if not uri: return if kind == "file": - resolved = resolve_file_reference(uri, _reference_roots(terminal)) + roots = _reference_roots(terminal) + # Stitching runs before direct resolution: a fragment of a + # reference the CLI hard-wrapped can resolve on its own (the + # leading fragment of a wrapped path is often an existing + # directory prefix), and the stitched whole is the truer + # reading. The stitcher only ever returns geometry-gated, + # existence-checked joins, so unwrapped references skip it fast. + resolved = _resolve_wrapped_at(terminal, uri, y, roots) + if resolved is None: + resolved = resolve_file_reference(uri, roots) if resolved is None: return # over-matched prose: leave the click to the terminal gesture.set_state(Gtk.EventSequenceState.CLAIMED) @@ -226,6 +240,48 @@ def _reference_roots(terminal: Vte.Terminal) -> list[str | None]: return [tab.current_agent_cwd(), tab.editor_root] +def _resolve_wrapped_at( + terminal: Vte.Terminal, candidate: str, y: float, roots: list[str | None] +) -> tuple[str, int | None, int | None] | None: + """Stitch a failed candidate with its neighbour rows (see linkpatterns. + resolve_wrapped_reference) — this half only turns the click's y into a + buffer row and fetches row texts. The clicked row is probed with a ±1 + slop: the y→row division ignores VTE's inner border, and the stitcher's + geometry gates reject the wrong rows anyway.""" + ch = terminal.get_char_height() + if ch <= 0: + return None + vadj = terminal.get_vadjustment() + scroll = vadj.get_value() if vadj is not None else 0.0 + if terminal.get_scroll_unit_is_pixels(): + row = int((scroll + y) // ch) + else: + row = int(scroll) + int(y // ch) + + def row_text(r: int) -> str: + if r < 0: + return "" + try: + text = terminal.get_text_range_format( + Vte.Format.TEXT, r, 0, r, terminal.get_column_count() + )[0] + except GLib.Error: + return "" + return (text or "").rstrip("\n") + + for r in (row, row - 1, row + 1): + resolved = resolve_wrapped_reference( + candidate, + row_text(r), + [row_text(r - 1), row_text(r - 2)], + [row_text(r + 1), row_text(r + 2), row_text(r + 3)], + roots, + ) + if resolved is not None: + return resolved + return None + + def _open_file_reference( terminal: Vte.Terminal, path: str, line: int | None, col: int | None ) -> None: diff --git a/tests/test_linkpatterns.py b/tests/test_linkpatterns.py index bf15891..b942572 100644 --- a/tests/test_linkpatterns.py +++ b/tests/test_linkpatterns.py @@ -9,7 +9,12 @@ import pytest -from collins.linkpatterns import FILE_PATTERN, URL_PATTERN, resolve_file_reference +from collins.linkpatterns import ( + FILE_PATTERN, + URL_PATTERN, + resolve_file_reference, + resolve_wrapped_reference, +) _RX = re.compile(URL_PATTERN) _FILE_RX = re.compile(FILE_PATTERN) @@ -231,3 +236,80 @@ def test_resolve_falls_back_to_literal_colon_name(project) -> None: def test_resolve_missing_returns_none(project) -> None: assert resolve_file_reference("collins/nope.py", [str(project)]) is None assert resolve_file_reference("collins/nope.py:3", [str(project)]) is None + + +# -- resolve_wrapped_reference --------------------------------------------- +# +# An emitter that hard-wraps output splits a long reference across real +# lines; each on-screen fragment matches FILE_PATTERN on its own but +# resolves nowhere. The stitcher joins a failed fragment with its neighbour +# rows' adjacent tokens, gated on the fragment touching its row's edge. + + +def _split(path: str, at: int) -> tuple[str, str]: + return path[:at], path[at:] + + +def test_stitch_downward_from_first_fragment(project) -> None: + path = str(project / "collins" / "foo.py") + head, tail = _split(path, 20) + resolved = resolve_wrapped_reference( + head, f" see {head}", [], [f" {tail} and more prose"], [] + ) + assert resolved == (path, None, None) + + +def test_stitch_upward_from_continuation_fragment(project) -> None: + path = str(project / "collins" / "foo.py") + head, tail = _split(path, 20) + resolved = resolve_wrapped_reference( + tail, f" {tail} and more prose", [f" see {head}"], [], [] + ) + assert resolved == (path, None, None) + + +def test_stitch_three_rows_from_middle_fragment(project) -> None: + path = str(project / "collins" / "foo.py") + head, rest = _split(path, 15) + middle, tail = _split(rest, 10) + resolved = resolve_wrapped_reference( + middle, f" {middle}", [f"wrote {head}"], [f" {tail}, done."], [] + ) + assert resolved == (path, None, None) + + +def test_stitch_keeps_line_suffix_on_continuation(project) -> None: + path = str(project / "collins" / "foo.py") + head, tail = _split(path, 20) + resolved = resolve_wrapped_reference( + head, f" {head}", [], [f" {tail}:12, then"], [] + ) + assert resolved == (path, 12, None) + + +def test_no_stitch_when_fragment_is_mid_row(project) -> None: + path = str(project / "collins" / "foo.py") + head, tail = _split(path, 20) + # The fragment has text after it on its own row, so it never wrapped — + # the row below must not be pulled in even though joining would resolve. + resolved = resolve_wrapped_reference( + head, f" {head} trailing words", [], [f" {tail}"], [] + ) + assert resolved is None + + +def test_stitch_that_resolves_nowhere_returns_none(project) -> None: + resolved = resolve_wrapped_reference( + "collins/nope", " collins/nope", [], [" .py either"], [str(project)] + ) + assert resolved is None + + +def test_prose_at_row_start_is_not_poisoned_by_row_above(project) -> None: + # `collins/foo.py` at the start of its row resolves directly and never + # reaches the stitcher; a *failing* start-of-row fragment tries the row + # above, and the bogus join just fails resolution. + resolved = resolve_wrapped_reference( + "collins/nope.py", " collins/nope.py here", ["ends with word"], [], [str(project)] + ) + assert resolved is None