From 07fc752d68f249832ad612039811b4510621593a Mon Sep 17 00:00:00 2001 From: Geoff Hackett Date: Mon, 3 Aug 2026 10:00:47 -0400 Subject: [PATCH 1/4] fix(terminal): continuation fragments of wrapped references are clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing of the stitcher showed clicks on the second half of a hard-wrapped path — the half holding the file name — did nothing, ever. Two causes: - A continuation fragment often contains no slash (`o.py:7)`), so FILE_PATTERN never matches it and the click handler bailed before the stitcher could run. When nothing under the pointer matches, the raw whitespace-delimited token at the clicked cell now stands in as the candidate; the stitcher's geometry gates and existence check keep prose clicks inert. - Joins were matched with an anchored match, which can only shed trailing junk. The emitter wraps whatever surrounds the path along with it, so fragments arrive glued to prefixes — `Read(/a/b/c` is Claude Code's own tool-call format — and any leading junk killed the join. Matching now searches the join, with span guards so a hit must overlap the clicked fragment itself. Verified live (headless, scrollback, pixel scrolling): both fragments of `⏺ Read(collins/fo` + `o.py:7)` resolve with the line suffix, a head token glued to `Update(` resolves, plain references still resolve, and prose tokens at row edges stay inert. 5 new unit tests, 64 pass total. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AXEJjBkzLLfVtVk6HhzFEn --- collins/linkpatterns.py | 43 +++++++++++++++++++++++++---- collins/terminal.py | 42 ++++++++++++++++++++++------ tests/test_linkpatterns.py | 56 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 14 deletions(-) diff --git a/collins/linkpatterns.py b/collins/linkpatterns.py index 18fe22e..1e2e697 100644 --- a/collins/linkpatterns.py +++ b/collins/linkpatterns.py @@ -99,6 +99,27 @@ def resolve_file_reference( return None +def token_at_column(text: str, col: int) -> str | None: + """The whitespace-delimited token covering column *col* of a screen row, + or None over whitespace / past the end of the text. + + A wrapped reference's continuation fragment frequently contains no slash + (`o.py:7)`), so it matches nothing and offers no click candidate at all — + yet it is the half holding the file *name*, the natural place to click. + The raw token under the pointer stands in as the candidate; the stitcher's + geometry gates and existence check keep arbitrary prose tokens inert. + """ + if col < 0 or col >= len(text) or text[col].isspace(): + return None + start = col + while start > 0 and not text[start - 1].isspace(): + start -= 1 + end = col + 1 + while end < len(text) and not text[end].isspace(): + end += 1 + return text[start:end] + + def resolve_wrapped_reference( candidate: str, row_text: str, @@ -148,10 +169,20 @@ def resolve_wrapped_reference( 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 + joined = up + candidate + down + # The emitter wraps whatever surrounds the path along with it, + # so a contributed token — or a token-derived candidate — often + # arrives glued to a prefix: `Read(/a/b/c` is Claude Code's own + # tool-call format. Searching (rather than an anchored match) + # sheds that junk wherever it sits; the span guards keep only + # hits that overlap the clicked fragment itself, so a join never + # resolves text the click didn't touch. + for m in _FILE_RX.finditer(joined): + if m.end() <= len(up): + continue + if m.start() >= len(up) + len(candidate): + break + 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 d1c04d9..bc78925 100644 --- a/collins/terminal.py +++ b/collins/terminal.py @@ -46,6 +46,7 @@ URL_PATTERN, resolve_file_reference, resolve_wrapped_reference, + token_at_column, ) from .promptcard import build_question_card # noqa: E402 from .providers import Provider, get_provider # noqa: E402 @@ -182,6 +183,19 @@ def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: match = "http://" + match uri = match if not uri: + # A wrapped reference's continuation fragment often contains no + # slash (`o.py:7)`) and so matches nothing — the half holding + # the file *name* offers no click candidate at all. Hand the + # stitcher the raw token under the pointer instead; its geometry + # gates and existence check keep prose clicks inert. + resolved = _resolve_wrapped_at( + terminal, None, x, y, _reference_roots(terminal) + ) + if resolved is None: + return + gesture.set_state(Gtk.EventSequenceState.CLAIMED) + path, line, col = resolved + _open_file_reference(terminal, path, line, col) return if kind == "file": roots = _reference_roots(terminal) @@ -191,7 +205,7 @@ def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: # 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) + resolved = _resolve_wrapped_at(terminal, uri, x, y, roots) if resolved is None: resolved = resolve_file_reference(uri, roots) if resolved is None: @@ -241,15 +255,22 @@ def _reference_roots(terminal: Vte.Terminal) -> list[str | None]: def _resolve_wrapped_at( - terminal: Vte.Terminal, candidate: str, y: float, roots: list[str | None] + terminal: Vte.Terminal, + candidate: str | None, + x: float, + 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 + resolve_wrapped_reference) — this half only turns the click's x/y into a + buffer cell and fetches row texts. With *candidate* None (nothing under + the pointer matched at all), the whitespace-delimited token at the cell + stands in as the candidate. 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: + cw = terminal.get_char_width() + if ch <= 0 or cw <= 0: return None vadj = terminal.get_vadjustment() scroll = vadj.get_value() if vadj is not None else 0.0 @@ -257,6 +278,7 @@ def _resolve_wrapped_at( row = int((scroll + y) // ch) else: row = int(scroll) + int(y // ch) + col = int(x // cw) def row_text(r: int) -> str: if r < 0: @@ -270,9 +292,13 @@ def row_text(r: int) -> str: return (text or "").rstrip("\n") for r in (row, row - 1, row + 1): + text = row_text(r) + cand = candidate if candidate is not None else token_at_column(text, col) + if not cand: + continue resolved = resolve_wrapped_reference( - candidate, - row_text(r), + cand, + text, [row_text(r - 1), row_text(r - 2)], [row_text(r + 1), row_text(r + 2), row_text(r + 3)], roots, diff --git a/tests/test_linkpatterns.py b/tests/test_linkpatterns.py index b942572..eba5699 100644 --- a/tests/test_linkpatterns.py +++ b/tests/test_linkpatterns.py @@ -14,6 +14,7 @@ URL_PATTERN, resolve_file_reference, resolve_wrapped_reference, + token_at_column, ) _RX = re.compile(URL_PATTERN) @@ -313,3 +314,58 @@ def test_prose_at_row_start_is_not_poisoned_by_row_above(project) -> None: "collins/nope.py", " collins/nope.py here", ["ends with word"], [], [str(project)] ) assert resolved is None + + +def test_stitch_upward_sheds_prefix_glued_to_head(project) -> None: + # Claude Code's tool-call format wraps as `⏺ Read(/…/fo` + `o.py)`: the + # row above contributes a token with `Read(` still attached, which must + # be shed for the join to match. + path = str(project / "collins" / "foo.py") + head, tail = _split(path, 20) + resolved = resolve_wrapped_reference( + tail, f" {tail})", [f"⏺ Read({head}"], [], [] + ) + assert resolved == (path, None, None) + + +def test_stitch_slashless_token_candidate(project) -> None: + # The continuation fragment of a wrapped path often has no slash at all + # (`o.py:7)`), so it is never a FILE_PATTERN match — the raw token under + # the pointer stands in as the candidate and still stitches, keeping the + # line suffix and shedding the trailing paren. + path = str(project / "collins" / "foo.py") + head, tail = _split(path, len(path) - 4) # tail = "o.py", no slash + resolved = resolve_wrapped_reference( + f"{tail}:7)", f" {tail}:7)", [f"⏺ Read({head}"], [], [] + ) + assert resolved == (path, 7, None) + + +def test_stitch_must_reach_into_the_candidate(project) -> None: + # A token candidate leading with a boundary character (`("x`) must not + # resolve to whatever existing path the row above happened to end with — + # the join has to cover text the click actually touched. + above = f"see {project / 'collins'}" + resolved = resolve_wrapped_reference('("x', '("x', [above], [], []) + assert resolved is None + + +def test_stitch_token_candidate_with_glued_prefix(project) -> None: + # Clicking the head fragment of `⏺ Update(collins` + `/foo.py)`: the head + # has no slash so it matches nothing, and the token under the pointer + # arrives with `Update(` glued on. The search sheds it and the join still + # resolves. + resolved = resolve_wrapped_reference( + "Update(collins", "⏺ Update(collins", [], [" /foo.py)"], [str(project)] + ) + assert resolved == (str(project / "collins" / "foo.py"), None, None) + + +def test_token_at_column() -> None: + text = " wrote o.py:7) done" + assert token_at_column(text, 8) == "o.py:7)" + assert token_at_column(text, 14) == "o.py:7)" + assert token_at_column(text, 1) is None # whitespace + assert token_at_column(text, 99) is None # past the end + assert token_at_column(text, -1) is None + assert token_at_column(text, 17) == "done" From ab826e953be7852aed9784f545fa1c0e2fd53ff0 Mon Sep 17 00:00:00 2001 From: Geoff Hackett Date: Mon, 3 Aug 2026 10:43:53 -0400 Subject: [PATCH 2/4] fix(terminal): read stitch rows from the visible screen, not the grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live testing in the app showed every wrapped-reference click still dead. Ground truth from running the real claude CLI inside a headless VTE: the CLI's repaint-style renderer leaves VTE's internal ring a full page away from what the adjustment describes (adjustment says rows 0-40, content lives at ring rows 42-80), so every adjustment-derived get_text_range read returned an empty row and the stitcher never saw any text. Feeding text into a terminal keeps ring and adjustment aligned, which is why the earlier probes never caught it. Row texts now come from the visible-screen snapshot (get_text_format), indexed by screen row straight from the click's y — no ring coordinates involved. Soft-wrapped rows come back joined, so they are re-chunked at the column count; pixel scrolling's partial top row is compensated by the adjustment fraction. Trade-off: neighbour rows scrolled out of the viewport are no longer reachable, which only matters for a wrapped reference straddling the viewport edge. Verified against the real CLI: a click map over every cell of a wrapped absolute path (both the plain and the Read(...):42 form, echoed at 64 columns) resolves on all four rows, box borders and prose stay inert, and a tilde path resolves to its directory. The feed-based probe (scrollback, pixel scrolling) still passes all 8 scenarios; 24 unit tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AXEJjBkzLLfVtVk6HhzFEn --- collins/terminal.py | 53 +++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/collins/terminal.py b/collins/terminal.py index bc78925..9e9b1b8 100644 --- a/collins/terminal.py +++ b/collins/terminal.py @@ -263,42 +263,53 @@ def _resolve_wrapped_at( ) -> 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 x/y into a - buffer cell and fetches row texts. With *candidate* None (nothing under + screen cell and fetches row texts. With *candidate* None (nothing under the pointer matched at all), the whitespace-delimited token at the cell - stands in as the candidate. 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.""" + stands in as the candidate. + + Row texts come from the *visible screen* snapshot, indexed by screen + row, never from grid-row reads: the grid APIs address VTE's internal + ring, and under a repaint-style renderer (claude's own UI) the ring + drifts a full page away from what the adjustment describes, so every + adjustment-derived get_text_range read comes back empty — discovered + live. 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() cw = terminal.get_char_width() if ch <= 0 or cw <= 0: return None + text = terminal.get_text_format(Vte.Format.TEXT) or "" + cols = int(terminal.get_column_count()) + rows: list[str] = [] + for line in text.split("\n"): + if len(line) <= cols: + rows.append(line) + else: + # VTE returns soft-wrapped screen rows joined into one logical + # line; a soft-wrapped row is by definition full-width, so + # fixed-size chunks reconstruct the screen rows exactly. + rows.extend(line[i : i + cols] for i in range(0, len(line), cols)) + # With pixel scrolling the viewport may start mid-row; the snapshot's + # first line is that partial row, so shift y by the fraction cut off. 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) + frac = 0.0 + if vadj is not None and terminal.get_scroll_unit_is_pixels(): + frac = vadj.get_value() % ch + row = int((y + frac) // ch) col = int(x // cw) 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") + return rows[r] if 0 <= r < len(rows) else "" for r in (row, row - 1, row + 1): - text = row_text(r) - cand = candidate if candidate is not None else token_at_column(text, col) + row_txt = row_text(r) + cand = candidate if candidate is not None else token_at_column(row_txt, col) if not cand: continue resolved = resolve_wrapped_reference( cand, - text, + row_txt, [row_text(r - 1), row_text(r - 2)], [row_text(r + 1), row_text(r + 2), row_text(r + 3)], roots, From e95f6d801f3204687045a1c462b40c057bd478bd Mon Sep 17 00:00:00 2001 From: Geoff Hackett Date: Mon, 3 Aug 2026 11:56:48 -0400 Subject: [PATCH 3/4] fix(terminal): stitch across wraps that fall on a directory boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrap-position sweep (every split of a long path, clicking every cell of both rows) found one failing shape: the wrap landing right after a character FILE_PATTERN refuses to end on — a head row ending `…/collins/` or `…/collins/.`. The match candidate arrives without the shed run, so the end-of-row gate never opened, stitching was skipped, and direct resolution then opened the *parent directory* instead of the file — the one hazard stitch-before-direct exists to prevent. The gate now also opens when the row ends with the candidate plus a run of exactly those shed characters, and the run seeds the downward join so `…/collins` ⏎ `linkpatterns.py` stitches back to `…/collins/linkpatterns.py`. Prose punctuation hanging off a reference at a row end opens the gate too, but its joins resolve nowhere and the click falls back to direct resolution as before. 3 new unit tests (27 pass); the 143-case sweep and the 8-scenario feed probe pass clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AXEJjBkzLLfVtVk6HhzFEn --- collins/linkpatterns.py | 22 ++++++++++++++++++++-- tests/test_linkpatterns.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/collins/linkpatterns.py b/collins/linkpatterns.py index 1e2e697..e576390 100644 --- a/collins/linkpatterns.py +++ b/collins/linkpatterns.py @@ -61,6 +61,12 @@ _SUFFIX = re.compile(r"(.+?):(\d+)(?::(\d+))?") _FILE_RX = re.compile(FILE_PATTERN) +# The characters FILE_PATTERN refuses to *end* a match on (_PATH_FINAL's +# exclusions). When a wrap falls right after one of them — a row ending +# `…/collins/` — the match candidate comes back without it, so the +# end-of-row stitch gate must tolerate a trailing run of exactly these. +_SHED_CHARS = ":.,;!?/" + # 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. @@ -142,8 +148,20 @@ def resolve_wrapped_reference( """ row = row_text.rstrip("\n") downs = [""] - if row.rstrip().endswith(candidate): - chain = "" + row_r = row.rstrip() + trail = None + if row_r.endswith(candidate): + trail = "" + else: + # A wrap that falls right after a character the pattern sheds + # (`…/collins/` ⏎ `linkpatterns.py`) leaves the candidate short of + # the row end; the shed run is part of the reference, so it seeds + # the downward join. + core = row_r.rstrip(_SHED_CHARS) + if core != row_r and core.endswith(candidate): + trail = row_r[len(core):] + if trail is not None: + chain = trail for below in rows_below[:_STITCH_ROWS_DOWN]: frag = below.strip() if not frag: diff --git a/tests/test_linkpatterns.py b/tests/test_linkpatterns.py index eba5699..e57a078 100644 --- a/tests/test_linkpatterns.py +++ b/tests/test_linkpatterns.py @@ -361,6 +361,43 @@ def test_stitch_token_candidate_with_glued_prefix(project) -> None: assert resolved == (str(project / "collins" / "foo.py"), None, None) +def test_stitch_downward_across_directory_boundary(project) -> None: + # The wrap can fall right after a slash: the row ends `…/collins/` but + # FILE_PATTERN refuses to end a match on `/`, so the candidate arrives + # without it. The shed slash must still join the fragments — without it + # the click "resolves" to the parent directory instead of the file. + path = str(project / "collins" / "foo.py") + head = str(project / "collins") + "/" + candidate = str(project / "collins") # what the pattern hands the handler + resolved = resolve_wrapped_reference( + candidate, f"● {head} ", [], [" foo.py and prose"], [] + ) + assert resolved == (path, None, None) + + +def test_stitch_downward_across_hidden_dir_boundary(project) -> None: + # Same shape, wrap after `/.` (start of a dotfile/dot-directory): two + # shed characters seed the join. + (project / "collins" / ".hidden").mkdir() + (project / "collins" / ".hidden" / "bar.py").write_text("x\n") + path = str(project / "collins" / ".hidden" / "bar.py") + candidate = str(project / "collins") + resolved = resolve_wrapped_reference( + candidate, f"● {candidate}/. ", [], [" hidden/bar.py:9, done"], [] + ) + assert resolved == (path, 9, None) + + +def test_prose_hanging_punctuation_does_not_false_join(project) -> None: + # `collins/foo.py, and` — the comma is prose, not a wrap; the shed-run + # gate opens but the join `foo.py,and` resolves nowhere and the click + # falls back to the direct resolution of the candidate itself. + resolved = resolve_wrapped_reference( + "collins/foo.py", " see collins/foo.py,", [], [" and more"], [str(project)] + ) + assert resolved is None + + def test_token_at_column() -> None: text = " wrote o.py:7) done" assert token_at_column(text, 8) == "o.py:7)" From ad803119584c810616a397d4881e88a806317247 Mon Sep 17 00:00:00 2001 From: Geoff Hackett Date: Wed, 5 Aug 2026 13:44:37 -0400 Subject: [PATCH 4/4] review: hoist roots lookup, honest stitch-cost comment, neighbour test Review follow-ups from PR 174 and PR 175: - _reference_roots is looked up once per click instead of once per branch. - The stitch-before-direct comment no longer claims unwrapped references skip the stitcher fast: a reference alone on its row opens both edge gates and does probe neighbours before the direct fallback wins; only mid-row references bail immediately. - New test pins the standalone-reference case: deceptive tokens on both neighbour rows produce only non-existent joins, the stitcher returns None, and direct resolution still gets the click. 28 tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AXEJjBkzLLfVtVk6HhzFEn --- collins/terminal.py | 10 +++++----- tests/test_linkpatterns.py | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/collins/terminal.py b/collins/terminal.py index 9e9b1b8..715054c 100644 --- a/collins/terminal.py +++ b/collins/terminal.py @@ -182,15 +182,14 @@ def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: if kind == "url" and match.startswith("www."): match = "http://" + match uri = match + roots = _reference_roots(terminal) if not uri: # A wrapped reference's continuation fragment often contains no # slash (`o.py:7)`) and so matches nothing — the half holding # the file *name* offers no click candidate at all. Hand the # stitcher the raw token under the pointer instead; its geometry # gates and existence check keep prose clicks inert. - resolved = _resolve_wrapped_at( - terminal, None, x, y, _reference_roots(terminal) - ) + resolved = _resolve_wrapped_at(terminal, None, x, y, roots) if resolved is None: return gesture.set_state(Gtk.EventSequenceState.CLAIMED) @@ -198,13 +197,14 @@ def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None: _open_file_reference(terminal, path, line, col) return if kind == "file": - 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. + # existence-checked joins. Mid-row references fail its edge + # gates immediately; a reference alone on its row does probe + # its neighbours before the direct fallback wins. resolved = _resolve_wrapped_at(terminal, uri, x, y, roots) if resolved is None: resolved = resolve_file_reference(uri, roots) diff --git a/tests/test_linkpatterns.py b/tests/test_linkpatterns.py index e57a078..92bd9ee 100644 --- a/tests/test_linkpatterns.py +++ b/tests/test_linkpatterns.py @@ -361,6 +361,27 @@ def test_stitch_token_candidate_with_glued_prefix(project) -> None: assert resolved == (str(project / "collins" / "foo.py"), None, None) +def test_standalone_reference_ignores_deceptive_neighbours(project) -> None: + # A reference alone on its row opens BOTH edge gates (the trimmed row + # equals the candidate), so the stitcher does probe its neighbours — but + # every join is a non-existent path, the stitcher returns None, and the + # click handler's fallback resolves the reference directly. Pins down + # that active neighbours can't poison a good standalone reference. + resolved = resolve_wrapped_reference( + "collins/foo.py", + " collins/foo.py", + ["prose above ending in .claude", "more prose"], + [" bar.txt below", " and more"], + [str(project)], + ) + assert resolved is None + assert resolve_file_reference("collins/foo.py", [str(project)]) == ( + str(project / "collins" / "foo.py"), + None, + None, + ) + + def test_stitch_downward_across_directory_boundary(project) -> None: # The wrap can fall right after a slash: the row ends `…/collins/` but # FILE_PATTERN refuses to end a match on `/`, so the candidate arrives