Skip to content
Merged
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: 65 additions & 0 deletions collins/linkpatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
60 changes: 58 additions & 2 deletions collins/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
84 changes: 83 additions & 1 deletion tests/test_linkpatterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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