diff --git a/collins/linkpatterns.py b/collins/linkpatterns.py index 18fe22e..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. @@ -99,6 +105,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, @@ -121,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: @@ -148,10 +187,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..9e9b1b8 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,38 +255,61 @@ 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 - geometry gates reject the wrong rows anyway.""" + resolve_wrapped_reference) — this half only turns the click's x/y into a + 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. + + 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() - if ch <= 0: + 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): + 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( - candidate, - row_text(r), + cand, + row_txt, [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..e57a078 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,95 @@ 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_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)" + 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"