Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 57 additions & 8 deletions collins/linkpatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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
83 changes: 60 additions & 23 deletions collins/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions tests/test_linkpatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
URL_PATTERN,
resolve_file_reference,
resolve_wrapped_reference,
token_at_column,
)

_RX = re.compile(URL_PATTERN)
Expand Down Expand Up @@ -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"