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
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@

Open the files and links your agent just mentioned.

`termscope` turns terminal output into a jump list. It reads the active pane's
visible terminal viewport, finds real files and URLs, then opens the selected
target beside the conversation you were already reading.
`termscope` turns terminal output into a jump list. It reads the visible
terminal viewport of every pane in the current window/tab, finds real files and
URLs, then opens the selected target beside the conversation you were already
reading.

```text
visible terminal viewport
Expand All @@ -35,11 +36,11 @@ Agents constantly mention files: stack traces, changed tests, docs, configs,
links, PRs. `termscope` lets you keep up without doing the little dance:
select text, copy, cd, paste, fix the path, add the line number.

If it's visible in the pane and exists in the repo, you can jump to it.
If it's visible in the window and exists in the repo, you can jump to it.

Termscope stays conservative:

- scans only the active pane's visible text
- scans only the visible text of the current window's panes
- verifies paths against the repo/worktree on disk
- preserves `file:line` targets
- falls back to a full repo picker when no visible file matches
Expand Down Expand Up @@ -126,6 +127,9 @@ File picker controls:
| `Ctrl-Y` | Agent pane: send `/plannotator-annotate <file>`; shell pane: run `plannotator annotate <file>` |
| `Ctrl-S` | Toggle appearance order / alphabetical sort |

Visible URLs are listed after the files. On a URL row, `Enter`/`Ctrl-O` open it
with the default opener and `Ctrl-Y` copies it.

Link picker controls:

| Key | Action |
Expand Down
2 changes: 1 addition & 1 deletion cable/termscope-alpha.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ input_prompt = "> "

[ui.preview_panel]
size = 60
footer = "Enter Neovim · Ctrl-O Default app · Ctrl-Y Plannotator"
footer = "Enter Neovim · Ctrl-O Default app · Ctrl-Y Plannotator · URLs: Enter opens, Ctrl-Y copies"
border_type = "rounded"

[ui.results_panel]
Expand Down
2 changes: 1 addition & 1 deletion cable/termscope-appearance.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ input_prompt = "> "

[ui.preview_panel]
size = 60
footer = "Enter Neovim · Ctrl-O Default app · Ctrl-Y Plannotator"
footer = "Enter Neovim · Ctrl-O Default app · Ctrl-Y Plannotator · URLs: Enter opens, Ctrl-Y copies"
border_type = "rounded"

[ui.results_panel]
Expand Down
115 changes: 102 additions & 13 deletions termscope
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,34 @@ class TmuxBackend(MultiplexerBackend):
return pane_path

def capture_pane_text(self, pane_id: str) -> str:
text = self._capture_single(pane_id)
if not pane_id:
return text
try:
listing = subprocess.run(
["tmux", "list-panes", "-t", pane_id, "-F", "#{pane_id}"],
text=True,
capture_output=True,
check=False,
)
canon = subprocess.run(
["tmux", "display-message", "-p", "-t", pane_id, "#{pane_id}"],
text=True,
capture_output=True,
check=False,
).stdout.strip()
except FileNotFoundError:
return text
if listing.returncode != 0:
log_event("capture_window_fallback", pane_id=pane_id, reason=listing.stderr.strip())
return text
texts = [text]
for pid in listing.stdout.split():
if pid and pid not in (canon, pane_id):
texts.append(self._capture_single(pid))
return "\n".join(texts)

def _capture_single(self, pane_id: str) -> str:
cmd = ["tmux", "capture-pane", "-pJ"]

# If the source pane is in copy mode, capture the scrolled viewport
Expand Down Expand Up @@ -263,13 +291,45 @@ class HerdrBackend(MultiplexerBackend):
return pane_path

def capture_pane_text(self, pane_id: str) -> str:
result = subprocess.run(
[self._herdr_bin(), "pane", "read", pane_id, "--source", "visible"],
text=True,
capture_output=True,
check=False,
)
return result.stdout
texts = []
for pid in self._tab_pane_ids(pane_id):
result = subprocess.run(
[self._herdr_bin(), "pane", "read", pid, "--source", "visible"],
text=True,
capture_output=True,
check=False,
)
texts.append(result.stdout)
return "\n".join(texts)

def _tab_pane_ids(self, pane_id: str) -> list[str]:
"""All panes in pane_id's tab — source pane first, skipping our own popup."""
# Sibling panes' relative paths resolve against the source pane's root.
try:
result = subprocess.run(
[self._herdr_bin(), "pane", "list"],
text=True,
capture_output=True,
check=False,
)
panes = json.loads(result.stdout)["result"]["panes"]
except (FileNotFoundError, json.JSONDecodeError, KeyError, TypeError):
log_event("capture_tab_fallback", pane_id=pane_id, reason="pane list unreadable")
return [pane_id]
tab_id = next((p.get("tab_id") for p in panes if p.get("pane_id") == pane_id), None)
if tab_id is None:
log_event("capture_tab_fallback", pane_id=pane_id, reason="source pane not listed")
return [pane_id]
own_id = os.environ.get("HERDR_PANE_ID")
ids = [
p.get("pane_id")
for p in panes
if p.get("tab_id") == tab_id and p.get("pane_id") not in (None, own_id)
]
if pane_id not in ids:
return [pane_id]
ids.sort(key=lambda pid: pid != pane_id)
return ids

def open_in_nvim_split(
self, path: Path, line: str, pane_path: Path, pane_id: str
Expand Down Expand Up @@ -474,6 +534,15 @@ def extract_visible_links(screen_text: str, sort: str = "appearance") -> list[st
return sort_items(matches, sort)


def merge_link_candidates(candidates: list[str], screen_text: str) -> list[str]:
"""Append visible URLs so one picker lists paths and links together."""
return candidates + [
link
for link in extract_visible_links(screen_text, sort="appearance")
if link not in candidates
]


# --------------------------------------------------------------------------- #
# File indexing
# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -923,13 +992,15 @@ def sort_file_list(files: list[str], sort: str = "appearance") -> list[str]:
return files


def run_tv_full_repo(search_root: Path, sort: str = "appearance") -> PickerResult | None:
"""Show all repo files when no visible candidate exists."""
def run_tv_full_repo(
search_root: Path, sort: str = "appearance", prepend: list[str] | None = None
) -> PickerResult | None:
"""Show all repo files when no visible file exists; visible URLs stay listed first."""
files = list_repo_files(search_root)
if not files:
return None
return _run_tv(
files,
(prepend or []) + files,
f"All files — {display_path(search_root)}",
("ctrl-o", "ctrl-y"),
sort=sort,
Expand Down Expand Up @@ -1125,6 +1196,9 @@ def preview_target(target_text: str, pane_path: Path) -> None:


def _render_preview(target_text: str, pane_path: Path) -> None:
if target_text.startswith(("http://", "https://")):
print(target_text)
return
parsed = parse_selected_target(target_text)
search_root = find_search_root(pane_path)
resolved = resolve_existing_path(parsed.path, pane_path, search_root)
Expand Down Expand Up @@ -1223,9 +1297,11 @@ def cmd_pick(args: argparse.Namespace) -> None:
screen_text = strip_ansi(screen_text)
repo_paths = list_repo_files(search_root)
sort = getattr(args, "sort", None) or os.environ.get("TERMSCOPE_SORT", "appearance")
candidates = extract_visible_candidates(
file_candidates = extract_visible_candidates(
screen_text, repo_paths, search_root, source_pane_path, sort="appearance"
)
candidates = merge_link_candidates(file_candidates, screen_text)
links = candidates[len(file_candidates):]

state = DebugState(
pane_path=str(pane_path),
Expand All @@ -1242,8 +1318,9 @@ def cmd_pick(args: argparse.Namespace) -> None:
)

# Filter out whitespace-only candidates that would produce an empty picker.
# Fallback keys off visible *files* only: a lone URL must not hide the repo.
real_candidates = [c for c in candidates if c.strip()]
visible_empty = len(real_candidates) == 0
visible_empty = not any(c.strip() for c in file_candidates)

if visible_empty:
log_event(
Expand All @@ -1268,7 +1345,7 @@ def cmd_pick(args: argparse.Namespace) -> None:
if visible_empty:
# No visible files — fall back to full repo listing so the picker
# is never empty.
picker_result = run_tv_full_repo(search_root, sort=sort)
picker_result = run_tv_full_repo(search_root, sort=sort, prepend=links)
if picker_result is None:
show_message("No files found in repo", backend)
log_event("pick_empty_repo", pane_id=pane_id)
Expand All @@ -1291,6 +1368,18 @@ def cmd_pick(args: argparse.Namespace) -> None:
return

key = picker_result.key or "enter"
if selection.startswith(("http://", "https://")):
state.mode = "url"
state.resolved_path = selection
debug_dump(state)
log_event("pick_url", pane_id=pane_id, key=key, url=selection)
if key == "ctrl-y":
copy_to_clipboard(selection)
show_message("Link copied to clipboard", backend)
return
open_url(selection)
return

if key == "ctrl-o":
mode = "default"
elif key == "ctrl-y":
Expand Down
2 changes: 1 addition & 1 deletion tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def test_television_channels_preserve_both_orders(self):
self.assertNotIn("'{}'", preview)
self.assertEqual(
channel["ui"]["preview_panel"]["footer"],
"Enter Neovim · Ctrl-O Default app · Ctrl-Y Plannotator",
"Enter Neovim · Ctrl-O Default app · Ctrl-Y Plannotator · URLs: Enter opens, Ctrl-Y copies",
)


Expand Down
Loading