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
4 changes: 2 additions & 2 deletions collins/chatsessionview.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Modified from the original agent-session-manager
# (https://github.com/r4nd3l/agent-session-manager, GPL-3.0) in the ghackett
# fork. Last modified: 2026-08-01. Full change history: git log for this file.
# fork. Last modified: 2026-08-03. Full change history: git log for this file.

"""A chat tab backed by a live headless `claude -p` stream-json session.

Expand Down Expand Up @@ -267,7 +267,7 @@ def _chip_path(tool_input: dict) -> str:
return ""

def _open_in_editor(self, path: str) -> None:
self.activate_action("win.open-in-editor", GLib.Variant("s", path))
self.activate_action("win.open-in-editor", GLib.Variant("(sii)", (path, 0, 0)))

# -- permissions -----------------------------------------------------------

Expand Down
24 changes: 23 additions & 1 deletion collins/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ def open_file(self, path: str | Path, restore_cursor: list | None = None) -> Non
existing = self._pages.get(key)
if existing is not None:
self._tab_view.set_selected_page(existing)
if restore_cursor:
# A clicked `path:line` reference re-targets a page that is
# already open (image pages have no buffer and stay put).
opened = self._open.get(key)
if opened is not None:
self._apply_cursor(opened, restore_cursor)
return
# Defense in depth behind the tree's own symlink filtering: every
# caller (tree activation, session restore, future pop-out) funnels
Expand Down Expand Up @@ -607,7 +613,23 @@ def _apply_cursor(self, opened: _OpenFile, pos) -> None:
_found, it = opened.buffer.get_iter_at_line(max(0, min(int(line), n_lines - 1)))
it.set_line_offset(max(0, min(int(offset), it.get_chars_in_line())))
opened.buffer.place_cursor(it)
opened.view.scroll_to_iter(it, 0.1, False, 0, 0)
# On a freshly loaded buffer, line heights are estimates until the
# view's validation idles run: scroll_to_iter silently stays at the
# top, and even scroll_to_mark's pending scroll lands hundreds of
# lines short on a large file. Scroll now for the cheap case, then
# re-issue at PRIORITY_LOW — validation runs at a far higher idle
# priority, so by the time the re-scroll fires the heights are exact
# (a clicked `path:602` reference must actually land on line 602).
opened.view.scroll_to_mark(opened.buffer.get_insert(), 0.1, False, 0.0, 0.0)
view = opened.view

def rescroll() -> bool:
buffer = view.get_buffer()
if buffer is not None:
view.scroll_to_mark(buffer.get_insert(), 0.1, False, 0.0, 0.0)
return GLib.SOURCE_REMOVE

GLib.idle_add(rescroll, priority=GLib.PRIORITY_LOW)

# -- notices ---------------------------------------------------------------

Expand Down
91 changes: 82 additions & 9 deletions collins/linkpatterns.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,92 @@
"""What counts as a link in terminal output.

Kept free of GTK/VTE imports so the pattern stays unit-testable on CI, which
has no VTE stack (see tests/conftest.py). The grammar is deliberately simpler
than GNOME Terminal's terminal-regex.h: a scheme'd URL or a bare www. host,
stopping at whitespace, quotes and angle brackets, and refusing to *end* on
punctuation that prose tends to hang off a link — so `(https://a.b/c).`
matches just `https://a.b/c`.

The pattern sticks to syntax PCRE2 and Python's `re` share: VTE compiles it
with PCRE2 at runtime, the tests exercise it with `re`.
Kept free of GTK/VTE imports so the patterns stay unit-testable on CI, which
has no VTE stack (see tests/conftest.py). Two grammars, each deliberately
simpler than GNOME Terminal's terminal-regex.h:

- ``URL_PATTERN``: a scheme'd URL or a bare www. host.
- ``FILE_PATTERN``: path-shaped text — absolute, ``~/``, or relative with at
least one slash, with Claude Code's optional ``:line[:col]`` suffix. A
path regex necessarily over-matches prose (``a/b``, dates, package
paths), so a hit is only a *candidate*: ``resolve_file_reference`` checks
the filesystem at click time, and a candidate that resolves nowhere is
ignored — the click falls through to the terminal. Bare filenames
(``terminal.py``) are deliberately out of the grammar; without a slash
the false-positive rate in ordinary prose is too high.

Both grammars stop at whitespace, quotes and brackets, and refuse to *end*
on punctuation that prose tends to hang off a reference — so
``(https://a.b/c).`` matches just ``https://a.b/c`` and
``collins/foo.py:12.`` keeps the final period out of the line suffix.

The patterns stick to syntax PCRE2 and Python's `re` share: VTE compiles
them with PCRE2 at runtime, the tests exercise them with `re`.
"""

import os
import re

# One body-then-final-char pair per alternative: the greedy body backtracks
# until the last character is something a URL can plausibly end on.
_BODY = "[^\\s<>\"']*"
_FINAL = "[^\\s<>\"'.,:;!?)\\]}]"

URL_PATTERN = f"(?:https?|ftp|file)://{_BODY}{_FINAL}|www\\.{_BODY}{_FINAL}"

# Paths bound tighter than URLs: brackets, backticks and quotes all end them
# (paths with spaces can't be bounded by a regex at all and stay out of
# scope), and `:` is reserved for the line suffix. The lookbehind demands a
# boundary character — or the start of the line — before the match. That
# same lookbehind keeps this grammar out of URLs: inside `https://a.b/c`
# every path-shaped start is preceded by `:`, `/` or a word character, none
# of which are boundaries, so URLs keep matching as URLs only.
_PATH_BOUNDARY = "\\s<>\"'`()\\[\\]{}"
_PATH_PRE = f"(?<![^{_PATH_BOUNDARY}])"
_PATH_CHAR = f"[^{_PATH_BOUNDARY}:]"
_PATH_SEG = f"[^{_PATH_BOUNDARY}:/]"
_PATH_FINAL = f"[^{_PATH_BOUNDARY}:.,;!?/]"
_LINE_SUFFIX = "(?::\\d+(?::\\d+)?)?"

FILE_PATTERN = (
f"{_PATH_PRE}"
f"(?:~?/{_PATH_CHAR}*{_PATH_FINAL}" # absolute, or ~/ home-relative
# Relative with >= 1 slash. The tail is optional so a bare directory
# reference (`collins/`) matches too; the absolute alternative keeps a
# mandatory tail so a lone `/` in prose never becomes a link to the
# filesystem root.
f"|{_PATH_SEG}+/(?:{_PATH_CHAR}*{_PATH_FINAL})?)"
f"{_LINE_SUFFIX}"
)

_SUFFIX = re.compile(r"(.+?):(\d+)(?::(\d+))?")


def resolve_file_reference(
text: str, roots: list[str | None]
) -> tuple[str, int | None, int | None] | None:
"""The file a FILE_PATTERN candidate actually points at, or None.

The regex is only a shape detector; this is the false-positive gate the
click runs. Strips the ``:line[:col]`` suffix (preferring that reading
over a literal filename containing colons), expands ``~``, and tries
relative paths against each of *roots* in order, skipping None entries.
Returns ``(path, line, col)`` with line/col as the reference wrote them
(1-based) or None where it carried no suffix.
"""
candidates: list[tuple[str, int | None, int | None]] = []
m = _SUFFIX.fullmatch(text)
if m is not None:
candidates.append(
(m.group(1), int(m.group(2)), int(m.group(3)) if m.group(3) else None)
)
candidates.append((text, None, None))
for raw, line, col in candidates:
expanded = os.path.expanduser(raw)
if os.path.isabs(expanded):
trials = [expanded]
else:
trials = [os.path.join(root, expanded) for root in roots if root]
for trial in trials:
if os.path.exists(trial):
return os.path.normpath(trial), line, col
return None
123 changes: 92 additions & 31 deletions collins/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
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 URL_PATTERN # noqa: E402
from .linkpatterns import FILE_PATTERN, URL_PATTERN, resolve_file_reference # noqa: E402
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 @@ -141,51 +141,65 @@ def _setup_links(terminal: Vte.Terminal) -> None:
"""Give links GNOME Terminal's behaviour: underline on hover, open on
Ctrl+click.

Covers both kinds of link a terminal shows: OSC 8 hyperlinks (what agent
Covers three kinds of link a terminal shows: OSC 8 hyperlinks (what agent
CLIs emit for file references — VTE ignores the escape entirely until
allow-hyperlink is switched on) and plain URLs in the output, matched by
regex the way GNOME Terminal matches them.
allow-hyperlink is switched on), plain URLs in the output matched by regex
the way GNOME Terminal matches them, and path-shaped file references
(`collins/foo.py:12`) matched by a second regex and validated against the
filesystem only at click time — VTE has no per-match callback, so the
hover underline can't know whether the file exists, but a click on a
candidate that resolves nowhere falls through to the terminal unclaimed
and costs the user nothing.
"""
terminal.set_allow_hyperlink(True)
try:
regex = Vte.Regex.new_for_match(
URL_PATTERN, len(URL_PATTERN.encode()), _PCRE2_MULTILINE
)
terminal.match_set_cursor_name(terminal.match_add_regex(regex, 0), "pointer")
except GLib.Error:
regex = None # VTE built without PCRE2: OSC 8 links still work

def on_launched(launcher: Gtk.UriLauncher | Gtk.FileLauncher, result) -> None:
tag_kinds: dict[int, str] = {}
for pattern, kind in ((URL_PATTERN, "url"), (FILE_PATTERN, "file")):
try:
launcher.launch_finish(result)
regex = Vte.Regex.new_for_match(
pattern, len(pattern.encode()), _PCRE2_MULTILINE
)
except GLib.Error:
pass # no handler for the scheme/type, or the user dismissed the chooser
continue # VTE built without PCRE2: OSC 8 links still work
tag = terminal.match_add_regex(regex, 0)
terminal.match_set_cursor_name(tag, "pointer")
tag_kinds[tag] = kind

def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None:
if not gesture.get_current_event_state() & Gdk.ModifierType.CONTROL_MASK:
return
kind = "url"
uri = terminal.check_hyperlink_at(x, y)
if uri is None and regex is not None:
match, _tag = terminal.check_match_at(x, y)
if match is not None and match.startswith("www."):
match = "http://" + match
if uri is None and tag_kinds:
match, tag = terminal.check_match_at(x, y)
if match is not None:
kind = tag_kinds.get(tag, "url")
if kind == "url" and match.startswith("www."):
match = "http://" + match
uri = match
if not uri:
return
if kind == "file":
resolved = resolve_file_reference(uri, _reference_roots(terminal))
if resolved is None:
return # over-matched prose: leave the click to the terminal
gesture.set_state(Gtk.EventSequenceState.CLAIMED)
path, line, col = resolved
_open_file_reference(terminal, path, line, col)
return
gesture.set_state(Gtk.EventSequenceState.CLAIMED)
if uri.startswith("file:"):
# A file: URI (or OSC 8 file: hyperlink) behaves exactly like a
# matched path reference — lightbox for images, editor inside
# the project, default app otherwise — however the CLI happened
# to emit it. path_from_file_uri sheds any #L10-style fragment.
path = editorfiles.path_from_file_uri(uri)
if path is not None and editorfiles.is_image_path(path):
_present_image(terminal, path)
if path is not None:
_open_file_reference(terminal, path, None, None)
return
# Open the file itself in its default app (what xdg-open does).
# UriLauncher would hand a file: URI to the portal, which only
# reveals it in the file manager. Gio.File also sheds any line
# fragment the emitter tacked on.
launcher = Gtk.FileLauncher.new(Gio.File.new_for_uri(uri))
else:
launcher = Gtk.UriLauncher.new(uri)
launcher.launch(terminal.get_root(), None, on_launched)
launcher.launch(terminal.get_root(), None, _on_link_launched)

# Capture phase, so Ctrl+click opens the link even when the running app
# has turned on mouse reporting (same trick as the context menu).
Expand All @@ -195,6 +209,49 @@ def on_pressed(gesture: Gtk.GestureClick, _n_press, x: float, y: float) -> None:
terminal.add_controller(click)


def _on_link_launched(launcher: Gtk.UriLauncher | Gtk.FileLauncher, result) -> None:
try:
launcher.launch_finish(result)
except GLib.Error:
pass # no handler for the scheme/type, or the user dismissed the chooser


def _reference_roots(terminal: Vte.Terminal) -> list[str | None]:
"""Where a relative file reference may be rooted: the running agent's cwd
first (Claude often works from a worktree or subdirectory), then the
tab's project root. Trying both catches the common cases cheaply."""
tab = terminal.get_ancestor(TerminalTab)
if tab is None:
return []
return [tab.current_agent_cwd(), tab.editor_root]


def _open_file_reference(
terminal: Vte.Terminal, path: str, line: int | None, col: int | None
) -> None:
"""Open a resolved file reference the way the reference deserves: images
in the lightbox (any readable path, even outside the project — its own
"Open in Editor" button handles the editor handoff), files inside the
clicking tab's project in that tab's editor at the referenced line, and
everything else — directories, outside-project files, no editor — in the
default app, as `file:` URIs always opened."""
if os.path.isfile(path):
if editorfiles.is_image_path(path):
_present_image(terminal, path)
return
tab = terminal.get_ancestor(TerminalTab)
if tab is not None and tab.can_open_in_editor(path):
# The window's action, not the tab directly: it also presents a
# popped-out editor window and applies the pop-out-on-small-
# screen policy. Line/col travel 1-based; 0 means none.
terminal.activate_action(
"win.open-in-editor", GLib.Variant("(sii)", (path, line or 0, col or 0))
)
return
launcher = Gtk.FileLauncher.new(Gio.File.new_for_path(path))
launcher.launch(terminal.get_root(), None, _on_link_launched)


def _present_image(terminal: Vte.Terminal, path: str) -> None:
"""A clicked file reference turned out to be an image: lightbox over the
window instead of handing it to the default app. Its "Open in Editor"
Expand All @@ -208,7 +265,9 @@ def _present_image(terminal: Vte.Terminal, path: str) -> None:
if can_edit:

def on_open() -> None:
terminal.activate_action("win.open-in-editor", GLib.Variant("s", path))
terminal.activate_action(
"win.open-in-editor", GLib.Variant("(sii)", (path, 0, 0))
)

present_image_lightbox(
terminal, path, can_open_in_editor=can_edit, on_open_in_editor=on_open
Expand Down Expand Up @@ -2369,15 +2428,17 @@ def can_open_in_editor(self, path: str | Path) -> bool:
refuse anything outside; this lets the window pick a better tab)."""
return self._editor is not None and editorfiles.is_inside(self._editor.root, path)

def open_in_editor(self, path: str | Path) -> None:
def open_in_editor(self, path: str | Path, cursor: list | None = None) -> None:
"""Open *path* in this tab's editor, revealing the panel if it is
closed. While the pane is popped out its window already shows it —
presenting that window is the caller's job (it owns the windows)."""
closed, optionally placing the cursor (*cursor* is open_file's
restore_cursor: [0-based line, char offset]). While the pane is
popped out its window already shows it — presenting that window is
the caller's job (it owns the windows)."""
if self._editor is None:
return
if not self._editor_detached and not self.editor_visible:
self.show_editor()
self._editor.open_file(path)
self._editor.open_file(path, restore_cursor=cursor)
self._editor.focus_default()

def set_editor_width_lookup(self, lookup) -> None:
Expand Down
Loading