diff --git a/README.md b/README.md index b2ed327..7c95177 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ single session — cost split, model mix, subagent tree — and step back out wi | `f` | Live fuzzy filter, fzf-style | | `H` / `C` / `D` | Switch data harness · colour theme · demo mode — from anywhere, overlays included | | `M` | Filter every view to one **machine** (fleet only) — the harness picker's twin, for `--pull`/`--remote` boxes | -| `L` | Relaunch the session in its own tool — tmux window/split/popup, or [your own launcher](docs/keys.md#custom-launchers); a session pulled from another machine reopens **on that machine**, over SSH | +| `L` | Relaunch the session in its own tool — tmux window/split/popup, Herdr tab/split, or [your own launcher](docs/keys.md#custom-launchers); a session pulled from another machine reopens **on that machine**, over SSH | | `n` | Note ✎ the selected session — why it cost what it did. Searchable, exported, kept in its own file | | `e` / `o` | Export the current view to CSV / open the project's directory | | `K` | **Remap anything** — opens `~/.config/opentab/keymap.conf` in `$EDITOR` and reloads it the moment you return; every key in every view, picker and prompt is configurable, and the footer/help re-label themselves live ([docs](docs/keys.md#remap-any-key)) | diff --git a/docs/architecture.md b/docs/architecture.md index d0a3540..3094088 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,6 +138,16 @@ by regex at paint time. - Subagent costs are recursive: `workflow_nodes` walks the parent chain so a root session's cost includes its whole subtree. +### Herdr session launch + +Herdr session launch is CLI-only. For a tab, OpenTab runs `herdr tab create`; for a split, +it runs `herdr pane split`. It parses `result.root_pane.pane_id` (tab) or +`result.pane.pane_id` (split) from the command's JSON response and then runs +`herdr pane run` with the session command. OpenTab does not configure or use a Herdr +socket, and does not call `layout.apply`, `pane.current`, or `pane.send_input`. The visible +and copyable command remains unchanged, including the single remote-command argument for +remote SSH launches. + ## The `$` what-if pricing model Every `Workflow` carries two cost snapshots: real recorded cost, and an diff --git a/docs/keys.md b/docs/keys.md index 4dd9aea..6a30156 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -84,7 +84,7 @@ worked 2h 15m (until 14:15)`. The Context tab still has the richer wall-clock st | `b` / `B` | Bookmark ★ the selected session (remembered between runs); `B` shows only bookmarks, within the active range | | `n` | Note ✎ on the selected session — *why* it cost what it did, which no token count records. Opens a prompt seeded with the existing note (`Enter` saves · `Ctrl-U` clears · `Ctrl-W` kills a word · `Esc` cancels); saving an empty note removes it. An annotated session shows a `✎` in every list and the note in its **Overview**; `f`/`/` searches note text too, and `e` exports it as a `note` column. Notes live in their own `~/.local/share/opentab/notes.json` and are written the moment you save. Off under `--demo` / `--no-state` | | `o` | Open the selected session's / project's directory | -| `L` | Launch the session in its own tool — `opencode --session` / `claude --resume` / `codex resume`. Then `w` window · `s` split · `v` vsplit · `p` popup · `y` copy the command (`w`/`s`/`v`/`p` need tmux or a [launcher hook](#custom-launchers); `y` copies anywhere). A session **pulled from another machine** reopens *there*: every target wraps the command in `ssh -t 'cd … && …'` using that machine's `remotes.json` entry, and `y` yanks that same line. A pulled box reached by `url` (no ssh target) offers only the yank | +| `L` | Launch the session in its own tool — `opencode --session` / `claude --resume` / `codex resume`. Then `w` window/tab · `s` right split · `v` lower split · `p` popup · `y` copy the command. tmux offers all spawn targets; Herdr offers a tab and both splits but no native popup; a [launcher hook](#custom-launchers) may offer all four. `y` copies anywhere. If tmux and Herdr are nested, OpenTab uses the innermost multiplexer. A session **pulled from another machine** reopens *there*: every target wraps the command in `ssh -t 'cd … && …'` using that machine's `remotes.json` entry, and `y` yanks that same line. A pulled box reached by `url` (no ssh target) offers only the yank | | `e` | Export the current list to a CSV in the working directory | ## Views & overlays diff --git a/docs/privacy.md b/docs/privacy.md index 933ac8c..f712a7c 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -60,11 +60,16 @@ bundled price snapshot serves everything otherwise. See ## External programs Run only on the key you press: your file opener (`open`/`xdg-open`, or Explorer on -Windows) for `o`, and for `L` either `tmux`, your own +Windows) for `o`, and for `L` either `tmux`, `herdr`, your own [launcher hook](keys.md#custom-launchers) (`~/.config/opentab/launcher`), or your clipboard tool (`pbcopy`/`wl-copy`/`xclip`/`xsel`) for its copy target. All are disabled in `--demo`. +When Herdr is selected, OpenTab invokes Herdr's CLI: `herdr tab create` or +`herdr pane split`, reads `result.root_pane.pane_id` or `result.pane.pane_id` from the +returned JSON, and then invokes `herdr pane run`. OpenTab has no Herdr socket +configuration and does not use the deprecated direct socket operations. + ## Demo mode `opentab --demo` is for showing the tool to other people without leaking your real diff --git a/src/opentab/__init__.py b/src/opentab/__init__.py index 54f3090..e1a0302 100644 --- a/src/opentab/__init__.py +++ b/src/opentab/__init__.py @@ -205,7 +205,12 @@ est_tokens, fuzzy_score, git_root, + herdr_create_argv, + herdr_launch, + in_herdr, in_tmux, + launch_backend, + launch_command, launcher_hook, model_row_1h_write, model_row_split, diff --git a/src/opentab/tui/app.py b/src/opentab/tui/app.py index c1fec37..a5bb01c 100644 --- a/src/opentab/tui/app.py +++ b/src/opentab/tui/app.py @@ -1,4 +1,5 @@ """App: state and the keyboard/mouse state machine.""" + from __future__ import annotations import argparse @@ -72,8 +73,6 @@ from opentab.tui.renderer import Renderer from opentab.util import ( fuzzy_score, - in_tmux, - launcher_hook, model_row_1h_write, model_row_split, month_bounds, @@ -451,6 +450,7 @@ def __init__( self._filter_before = "" # the query as it was when `/` opened the mode (Esc restores) self.launch_menu: Workflow | None = None # session awaiting an `L` launch-target key self.launch_menu_index = 0 # highlighted row in that picker + self.launch_menu_backend: str | None = None self.price_prompt = False # the "unpriced models found" startup prompt self._price_prompt_done = False # offered at most once per run self.prices_prompt_dismissed = False # "don't ask again" pref (persisted in state) @@ -3862,7 +3862,7 @@ def open_current(self) -> None: def resume_parts(self, workflow: Workflow) -> tuple[str, str] | None: # (project directory, bare resume command) for the selected session — - # the tmux launch paths pass the directory separately (-c/-d flags). + # launch backends receive the directory separately from the bare resume command. cli = RESUME_COMMANDS.get(workflow.source) directory = workflow.directory if not cli or not directory or directory == "(unknown)": @@ -3889,17 +3889,14 @@ def machine_ssh_target(self, workflow: Workflow) -> str | None: return targets.get(str(key)) or None def launch_parts(self, workflow: Workflow) -> tuple[str, str] | None: - # (start directory, command) for the spawn targets. Locally that is the project - # and the bare resume command; for a session pulled from another box it is the - # same command wrapped in ssh, started from HOME -- the remote project path does - # not exist here, and tmux's -c would refuse a directory it can't enter. + # The command stays in its copyable shell form for every launch backend. parts = self.resume_parts(workflow) if not parts: return None + directory, command = parts target = self.machine_ssh_target(workflow) if not target: - return parts - directory, command = parts + return directory, command return os.path.expanduser("~"), util.ssh_command(target, directory, command) def resume_command(self, workflow: Workflow) -> str | None: @@ -3916,11 +3913,9 @@ def resume_command(self, workflow: Workflow) -> str | None: return f"cd {shlex.quote(directory)} && {command}" def launch_available(self) -> bool: - # The spawn targets can only land next to opentab from inside tmux (its - # window/split/popup commands) or through a user launcher hook (which can - # drive zellij/kitty/etc. anywhere). Outside both the `L` menu still opens, - # but only offers copying the resume command (see launch_targets). - return in_tmux() or launcher_hook() is not None + # launch_backend picks the hook or innermost supported multiplexer when the + # picker opens; retain that backend snapshot for its rows and dispatch. + return self.launch_menu_backend is not None def unreachable_machine(self) -> str | None: # The machine name when the session in the `L` picker was pulled from a box we @@ -3938,17 +3933,22 @@ def unreachable_machine(self) -> str | None: return None if self.machine_ssh_target(session) else name def launch_targets(self) -> tuple[tuple[str, str, str], ...]: - # The picker rows actually offered here: everything inside tmux (or with a - # launcher hook); only the clipboard copy outside — copying needs neither, and - # it is also all that is left for a machine with no ssh target. + # Herdr can create focused tabs and splits, but has no general popup API. if self.launch_available() and not self.unreachable_machine(): - return self.LAUNCH_TARGETS + targets = self.LAUNCH_TARGETS + if self.launch_menu_backend == "herdr": + targets = tuple(target for target in targets if target[1] != "popup") + targets = tuple( + (key, kind, "new tab" if kind == "window" else label) + for key, kind, label in targets + ) + return targets return tuple(t for t in self.LAUNCH_TARGETS if t[1] == "copy") def launch_current(self) -> None: # `L`: open the launch menu (window/split/popup/copy — handled by - # handle_launch_key on the next keystroke). Outside tmux/hook the menu - # narrows to the copy target instead of disappearing (launch_targets). + # handle_launch_key on the next keystroke). Without a supported launch backend + # (tmux, Herdr, or a hook), the menu narrows to copying (launch_targets). if self.store.demo: self.notify("launch disabled in demo mode", "error") return @@ -3961,6 +3961,7 @@ def launch_current(self) -> None: return self.launch_menu = session self.launch_menu_index = 0 + self.launch_menu_backend = util.launch_backend() def launch_session(self) -> Workflow | None: if self.view == "session" or (self.view == "zoom" and self.on_sessions_tab): @@ -4021,7 +4022,7 @@ def _do_launch(self, session: Workflow, kind: str) -> None: self.notice = "launch cancelled" return directory, command = parts - error = util.tmux_launch(kind, directory, command) + error = util.launch_command(kind, directory, command, self.launch_menu_backend) if error: self.notify(f"launch failed: {error}", "error") else: diff --git a/src/opentab/tui/renderer.py b/src/opentab/tui/renderer.py index b433eee..cebab6b 100644 --- a/src/opentab/tui/renderer.py +++ b/src/opentab/tui/renderer.py @@ -82,7 +82,6 @@ context_compactions, context_size, fuzzy_score, - launcher_hook, tool_namespace, unicode_screen, ) @@ -5480,9 +5479,11 @@ def draw_launch_menu(self, stdscr: curses.window, scr_h: int, scr_w: int) -> Non if unreachable: headline = f"pulled from {unreachable} — no ssh target, copy instead:" elif not self.launch_available(): - headline = "no tmux / launcher hook — copy instead:" + headline = "no tmux / herdr / launcher hook — copy instead:" else: - via = "launcher hook" if launcher_hook() else "tmux" + via = ( + "launcher hook" if self.launch_menu_backend == "hook" else self.launch_menu_backend + ) headline = f"open in {via}:" if not remote else f"open on {remote} (ssh) in {via}:" idx = self.launch_menu_index % len(targets) lines = [ diff --git a/src/opentab/util.py b/src/opentab/util.py index 58a580c..ffc1512 100644 --- a/src/opentab/util.py +++ b/src/opentab/util.py @@ -1,6 +1,8 @@ """Clipboard, launchers, git roots, fuzzy match, date/range parsing, tool labels.""" + from __future__ import annotations +import json import locale import os import re @@ -642,6 +644,154 @@ def in_tmux() -> bool: return bool(os.environ.get("TMUX")) +def in_herdr() -> bool: + """Whether Herdr identifies the current pane through its official marker.""" + return env_flag("HERDR_ENV") is True + + +def _current_tty() -> str | None: + try: + return os.ttyname(sys.stdin.fileno()) + except (AttributeError, OSError, ValueError): + return None + + +def _tmux_pane_tty() -> str | None: + pane = os.environ.get("TMUX_PANE") + if not pane: + return None + try: + proc = subprocess.run( + ["tmux", "display-message", "-p", "-t", pane, "#{pane_tty}"], + capture_output=True, + text=True, + timeout=1, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + tty = proc.stdout.strip() + return tty or None + + +def launch_backend() -> str | None: + """Select the explicit hook or the innermost supported multiplexer.""" + if launcher_hook() is not None: + return "hook" + tmux = in_tmux() + herdr = in_herdr() + if tmux and not herdr: + return "tmux" + if herdr and not tmux: + return "herdr" + if not tmux: + return None + + current_tty = _current_tty() + pane_tty = _tmux_pane_tty() + if current_tty and pane_tty: + return "tmux" if current_tty == pane_tty else "herdr" + term = os.environ.get("TERM", "") + return "tmux" if term.startswith(("tmux", "screen")) else "herdr" + + +def herdr_create_argv(kind: str, directory: str) -> list[str]: + """Build the focused Herdr tab or split creation command.""" + configured = os.environ.get("HERDR_BIN_PATH") + herdr = ( + configured + if configured and os.path.isfile(configured) and os.access(configured, os.X_OK) + else "herdr" + ) + if kind == "window": + argv = [herdr, "tab", "create"] + workspace = os.environ.get("HERDR_WORKSPACE_ID") + if workspace: + argv.extend(["--workspace", workspace]) + return argv + ["--cwd", directory, "--focus"] + if kind == "hsplit": + return [ + herdr, + "pane", + "split", + "--current", + "--direction", + "right", + "--cwd", + directory, + "--focus", + ] + if kind == "vsplit": + return [ + herdr, + "pane", + "split", + "--current", + "--direction", + "down", + "--cwd", + directory, + "--focus", + ] + if kind == "popup": + raise ValueError("herdr does not support popups") + raise ValueError(f"unknown Herdr launch kind: {kind}") + + +def _herdr_failure(stage: str, proc) -> str: + detail = (proc.stderr or proc.stdout).strip() + return f"herdr {stage} failed: {detail or f'exit status {proc.returncode}'}" + + +def _herdr_cli_launch(kind: str, directory: str, command: str) -> str | None: + """Create a focused Herdr target, then run the complete command inside it.""" + create_argv = herdr_create_argv(kind, directory) + try: + created = subprocess.run(create_argv, capture_output=True, text=True, timeout=10) + except subprocess.TimeoutExpired: + return "herdr create timed out" + except OSError as exc: + return f"herdr create failed: {exc}" + if created.returncode != 0: + return _herdr_failure("create", created) + try: + payload = json.loads(created.stdout) + except json.JSONDecodeError: + return "herdr create returned invalid JSON" + + key = "root_pane" if kind == "window" else "pane" + result = payload.get("result") if isinstance(payload, dict) else None + pane = result.get(key) if isinstance(result, dict) else None + pane_id = pane.get("pane_id") if isinstance(pane, dict) else None + if not isinstance(pane_id, str) or not pane_id: + return f"herdr create returned no valid pane ID for {key}" + + try: + ran = subprocess.run( + [create_argv[0], "pane", "run", pane_id, command], + capture_output=True, + text=True, + timeout=10, + ) + except subprocess.TimeoutExpired: + return f"herdr pane run timed out for pane {pane_id}" + except OSError as exc: + return f"herdr pane run failed for pane {pane_id}: {exc}" + if ran.returncode != 0: + return f"{_herdr_failure('pane run', ran)} for pane {pane_id}" + return None + + +def herdr_launch(kind: str, directory: str, command: str) -> str | None: + """Create a Herdr tab or split through its CLI, then run the command in it.""" + if kind not in ("window", "hsplit", "vsplit"): + if kind == "popup": + return "herdr does not support popups" + return f"unknown Herdr launch kind: {kind}" + return _herdr_cli_launch(kind, directory, command) + + def launcher_hook() -> str | None: # Optional user hook, git-hooks style: an executable that receives every # launch-menu action instead of the built-in tmux commands, so launches can @@ -703,12 +853,7 @@ def tmux_launch_argv(kind: str, directory: str, command: str) -> list[str]: return ["tmux", "display-popup", "-E", "-d", directory, "-w", "85%", "-h", "75%", command] -def tmux_launch(kind: str, directory: str, command: str) -> str | None: - """Run a resume command in a new tmux window/split/popup — or hand the - whole action to the user's launcher hook when one is installed. Returns an - error message, or None when the launch was issued.""" - hook = launcher_hook() - argv = [hook, kind, directory, command] if hook else tmux_launch_argv(kind, directory, command) +def _run_tmux_or_hook(kind: str, argv: list[str], label: str) -> str | None: try: if kind == "popup": # display-popup (and popup hooks that wrap it) can block until the @@ -719,10 +864,40 @@ def tmux_launch(kind: str, directory: str, command: str) -> str | None: except (OSError, subprocess.SubprocessError) as exc: return str(exc) if proc.returncode != 0: - return (proc.stderr or ("launcher hook failed" if hook else "tmux failed")).strip() + return (proc.stderr or f"{label} failed").strip() return None +def launch_command( + kind: str, + directory: str, + command: str, + backend: str | None = None, +) -> str | None: + """Run a resume command through the configured hook or active backend.""" + backend = launch_backend() if backend is None else backend + if backend == "hook": + hook = launcher_hook() + if hook is None: + return "launcher hook unavailable" + return _run_tmux_or_hook(kind, [hook, kind, directory, command], "launcher hook") + if backend == "tmux": + return _run_tmux_or_hook(kind, tmux_launch_argv(kind, directory, command), "tmux") + if backend == "herdr": + try: + return herdr_launch(kind, directory, command) + except ValueError as exc: + return str(exc) + return "no supported launch backend available" + + +def tmux_launch(kind: str, directory: str, command: str) -> str | None: + """Run a resume command through tmux, retaining the legacy hook override.""" + hook = launcher_hook() + argv = [hook, kind, directory, command] if hook else tmux_launch_argv(kind, directory, command) + return _run_tmux_or_hook(kind, argv, "launcher hook" if hook else "tmux") + + def normalize_project_path(directory: str) -> str: # Canonicalize a Windows drive path so the SAME directory recorded by different # tools collapses to one project. OpenCode (a JS app) stores forward slashes diff --git a/tests/__init__.py b/tests/__init__.py index 2dcef63..c131872 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -32,6 +32,17 @@ ): os.environ[_var] = os.path.join(_ISOLATED_HOME.name, _sub) +# Multiplexer markers describe the developer's terminal, not the isolated test process. +for _var in ( + "TMUX", + "TMUX_PANE", + "HERDR_ENV", + "HERDR_BIN_PATH", + "HERDR_WORKSPACE_ID", + "OPENTAB_LAUNCHER", +): + os.environ.pop(_var, None) + import opentab as ot # noqa: E402 (must follow the sys.path shim and XDG isolation above) ot.invalidate_price_cache() diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index b6f40f8..c4ef183 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -2,6 +2,7 @@ import contextlib import os +from types import SimpleNamespace import opentab as ot @@ -2043,17 +2044,19 @@ def test_launch_menu_opens_in_tmux_and_copy_only_outside(): app.view = "zoom" app.tab = app.current_tabs().index("Sessions") old_tmux = os.environ.get("TMUX") - real_launch, real_copy = ot.util.tmux_launch, ot.util.copy_to_clipboard + real_launch, real_copy = ot.util.launch_command, ot.util.copy_to_clipboard launches, copies = [], [] try: - ot.util.tmux_launch = lambda kind, d, c: launches.append((kind, d, c)) or None + ot.util.launch_command = lambda kind, d, c, backend=None: ( + launches.append((kind, d, c, backend)) or None + ) ot.util.copy_to_clipboard = lambda v: copies.append(v) or True os.environ["TMUX"] = "/tmp/tmux-1/default,1,0" app.handle_key(None, ord("L")) assert app.launch_menu is not None and not launches # menu open, nothing run app.handle_key(None, ord("w")) assert app.launch_menu is None - assert launches == [("window", "/repo/a", "claude --resume ses_1")] + assert launches[0][:3] == ("window", "/repo/a", "claude --resume ses_1") # Esc cancels without launching app.handle_key(None, ord("L")) app.handle_key(None, 27) @@ -2075,7 +2078,7 @@ def test_launch_menu_opens_in_tmux_and_copy_only_outside(): assert app.launch_menu is None assert copies[-1] == "cd /repo/a && claude --resume ses_1" finally: - ot.util.tmux_launch = real_launch + ot.util.launch_command = real_launch ot.util.copy_to_clipboard = real_copy if old_tmux is None: os.environ.pop("TMUX", None) @@ -2083,6 +2086,34 @@ def test_launch_menu_opens_in_tmux_and_copy_only_outside(): os.environ["TMUX"] = old_tmux +def test_tmux_launch_menu_snapshot_ignores_a_hook_added_after_opening(): + a = workflow("ses_1", "2026-06-01 12:00:00", directory="/repo/a") + a.source = "Claude Code" + app = app_with([a]) + app.view = "zoom" + app.tab = app.current_tabs().index("Sessions") + real_backend = ot.util.launch_backend + real_hook = ot.util.launcher_hook + real_run = ot.util.subprocess.run + calls = [] + try: + ot.util.launch_backend = lambda: "tmux" + ot.util.launcher_hook = lambda: None + app.handle_key(None, ord("L")) + assert app.launch_menu_backend == "tmux" + + ot.util.launcher_hook = lambda: "/tmp/launcher" + ot.util.subprocess.run = lambda argv, **kwargs: ( + calls.append(argv) or SimpleNamespace(returncode=0, stderr="") + ) + app.handle_key(None, ord("w")) + assert calls == [["tmux", "new-window", "-c", "/repo/a", "claude --resume ses_1"]] + finally: + ot.util.launch_backend = real_backend + ot.util.launcher_hook = real_hook + ot.util.subprocess.run = real_run + + def test_launch_menu_is_navigable_with_jk_and_enter(): a = workflow("ses_1", "2026-06-01 12:00:00", directory="/repo/a") a.source = "Claude Code" @@ -2090,10 +2121,12 @@ def test_launch_menu_is_navigable_with_jk_and_enter(): app.view = "zoom" app.tab = app.current_tabs().index("Sessions") old_tmux = os.environ.get("TMUX") - real_launch = ot.util.tmux_launch + real_launch = ot.util.launch_command launches = [] try: - ot.util.tmux_launch = lambda kind, d, c: launches.append((kind, d, c)) or None + ot.util.launch_command = lambda kind, d, c, backend=None: ( + launches.append((kind, d, c, backend)) or None + ) os.environ["TMUX"] = "/tmp/tmux-1/default,1,0" app.handle_key(None, ord("L")) assert app.launch_menu is not None and app.launch_menu_index == 0 # starts at "window" @@ -2107,15 +2140,97 @@ def test_launch_menu_is_navigable_with_jk_and_enter(): app.handle_key(None, ord("j")) # -> hsplit app.handle_key(None, 10) # Enter runs the highlighted target assert app.launch_menu is None - assert launches == [("hsplit", "/repo/a", "claude --resume ses_1")] + assert launches[0][:3] == ("hsplit", "/repo/a", "claude --resume ses_1") finally: - ot.util.tmux_launch = real_launch + ot.util.launch_command = real_launch if old_tmux is None: os.environ.pop("TMUX", None) else: os.environ["TMUX"] = old_tmux +def test_herdr_launch_menu_uses_tabs_splits_copy_and_one_backend_snapshot(): + a = workflow("ses_1", "2026-06-01 12:00:00", directory="/repo/a") + a.source = "Claude Code" + app = app_with([a]) + app.view = "zoom" + app.tab = app.current_tabs().index("Sessions") + real_backend = ot.util.launch_backend + real_launch = ot.util.launch_command + real_copy = ot.util.copy_to_clipboard + launches, copies = [], [] + try: + ot.util.launch_backend = lambda: "herdr" + ot.util.launch_command = lambda kind, d, c, backend=None: ( + launches.append((kind, d, c, backend)) or None + ) + ot.util.copy_to_clipboard = lambda command: copies.append(command) or True + app.handle_key(None, ord("L")) + assert app.launch_menu_backend == "herdr" + targets = app.launch_targets() + assert [kind for _key, kind, _label in targets] == ["window", "hsplit", "vsplit", "copy"] + assert targets[0][2] == "new tab" + + screen = FakeScreen(30, 100) + real_pair = ot.curses.color_pair + ot.curses.color_pair = lambda n: 0 + try: + app.renderer.draw_launch_menu(screen, 30, 100) + finally: + ot.curses.color_pair = real_pair + text = screen_text(screen) + assert "open in herdr:" in text and "new tab" in text and "popup" not in text + + # Changing detection while the menu is open must not change its rows or dispatch. + ot.util.launch_backend = lambda: "tmux" + app.handle_key(None, ord("w")) + ot.util.launch_backend = lambda: "herdr" + _launch(app, "ses_1", ord("s")) + _launch(app, "ses_1", ord("v")) + _launch(app, "ses_1", ord("y")) + assert [row[0] for row in launches] == ["window", "hsplit", "vsplit"] + assert all(row[-1] == "herdr" for row in launches) + assert copies == ["cd /repo/a && claude --resume ses_1"] + finally: + ot.util.launch_backend = real_backend + ot.util.launch_command = real_launch + ot.util.copy_to_clipboard = real_copy + + +def test_launcher_hook_in_herdr_restores_popup_and_launch_errors_close_menu(): + a = workflow("ses_1", "2026-06-01 12:00:00", directory="/repo/a") + a.source = "Claude Code" + app = app_with([a]) + app.view = "zoom" + app.tab = app.current_tabs().index("Sessions") + real_backend = ot.util.launch_backend + real_launch = ot.util.launch_command + try: + ot.util.launch_backend = lambda: "hook" + ot.util.launch_command = lambda kind, d, c, backend=None: "hook rejected popup" + app.handle_key(None, ord("L")) + assert [kind for _key, kind, _label in app.launch_targets()] == [ + "window", + "hsplit", + "vsplit", + "popup", + "copy", + ] + screen = FakeScreen(30, 100) + real_pair = ot.curses.color_pair + ot.curses.color_pair = lambda n: 0 + try: + app.renderer.draw_launch_menu(screen, 30, 100) + finally: + ot.curses.color_pair = real_pair + assert "open in launcher hook:" in screen_text(screen) + app.handle_key(None, ord("p")) + assert app.launch_menu is None and "launch failed: hook rejected popup" in app.notice + finally: + ot.util.launch_backend = real_backend + ot.util.launch_command = real_launch + + def _remote_launch_app(targets): # A two-box fleet: "laptop" is the live local machine, "giant" a pulled one whose # remotes.json key is its own name. `targets` is what main() injects for `L`. @@ -2138,6 +2253,60 @@ def _launch(app, session_id, key): app.handle_key(None, key) +def test_launch_menu_uses_the_innermost_nested_multiplexer(): + a = workflow("ses_1", "2026-06-01 12:00:00", directory="/repo/a") + a.source = "Claude Code" + app = app_with([a]) + app.view = "zoom" + app.tab = app.current_tabs().index("Sessions") + old_env = {key: os.environ.get(key) for key in ("TMUX", "TMUX_PANE", "HERDR_ENV")} + real_current = ot.util._current_tty + real_tmux = ot.util._tmux_pane_tty + try: + os.environ["TMUX"] = "tmux" + os.environ["TMUX_PANE"] = "%1" + os.environ["HERDR_ENV"] = "1" + ot.util._current_tty = lambda: "/dev/pts/7" + ot.util._tmux_pane_tty = lambda: "/dev/pts/7" + app.handle_key(None, ord("L")) + assert app.launch_menu_backend == "tmux" + app.handle_key(None, 27) + + ot.util._tmux_pane_tty = lambda: "/dev/pts/3" + app.handle_key(None, ord("L")) + assert app.launch_menu_backend == "herdr" + assert "popup" not in [kind for _key, kind, _label in app.launch_targets()] + app.handle_key(None, 27) + finally: + ot.util._current_tty = real_current + ot.util._tmux_pane_tty = real_tmux + for key, value in old_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def test_remote_herdr_launch_heading_names_host_and_backend(): + app = _remote_launch_app({"giant": "root@giant"}) + real_backend = ot.util.launch_backend + try: + ot.util.launch_backend = lambda: "herdr" + app.session_stack = [] + app.drill_into_session("ses_there") + app.handle_key(None, ord("L")) + screen = FakeScreen(30, 100) + real_pair = ot.curses.color_pair + ot.curses.color_pair = lambda n: 0 + try: + app.renderer.draw_launch_menu(screen, 30, 100) + finally: + ot.curses.color_pair = real_pair + assert "open on root@giant (ssh) in herdr:" in screen_text(screen) + finally: + ot.util.launch_backend = real_backend + + def test_launch_reopens_a_pulled_session_on_its_own_machine_over_ssh(): # A session you pulled from another box ran THERE: its id is that box's, and its # project path may not even exist here. Spawning it locally would resume the wrong @@ -2146,14 +2315,16 @@ def test_launch_reopens_a_pulled_session_on_its_own_machine_over_ssh(): # command anyone can paste. app = _remote_launch_app({"giant": "root@giant"}) old_tmux = os.environ.get("TMUX") - real_launch, real_copy = ot.util.tmux_launch, ot.util.copy_to_clipboard + real_launch, real_copy = ot.util.launch_command, ot.util.copy_to_clipboard launches, copies = [], [] try: - ot.util.tmux_launch = lambda kind, d, c: launches.append((kind, d, c)) or None + ot.util.launch_command = lambda kind, d, c, backend=None: ( + launches.append((kind, d, c, backend)) or None + ) ot.util.copy_to_clipboard = lambda v: copies.append(v) or True os.environ["TMUX"] = "/tmp/tmux-1/default,1,0" _launch(app, "ses_there", ord("w")) - kind, directory, command = launches[0] + kind, directory, command, _backend = launches[0] # -t (the agent CLIs are interactive) and ONE quoted remote argument, so the cd # and the resume happen in the same remote shell. assert kind == "window" @@ -2178,11 +2349,11 @@ def test_launch_reopens_a_pulled_session_on_its_own_machine_over_ssh(): app.handle_key(None, 27) # The local box is untouched: same session list, same plain local launch. _launch(app, "ses_here", ord("w")) - assert launches[-1] == ("window", "/repo/a", "claude --resume ses_here") + assert launches[-1][:3] == ("window", "/repo/a", "claude --resume ses_here") _launch(app, "ses_here", ord("y")) assert copies[-1] == "cd /repo/a && claude --resume ses_here" finally: - ot.util.tmux_launch, ot.util.copy_to_clipboard = real_launch, real_copy + ot.util.launch_command, ot.util.copy_to_clipboard = real_launch, real_copy if old_tmux is None: os.environ.pop("TMUX", None) else: @@ -2195,10 +2366,12 @@ def test_launch_on_a_machine_with_no_ssh_target_offers_only_the_yank(): # session id here, and the picker says why. app = _remote_launch_app({}) old_tmux = os.environ.get("TMUX") - real_launch = ot.util.tmux_launch + real_launch = ot.util.launch_command launches = [] try: - ot.util.tmux_launch = lambda kind, d, c: launches.append((kind, d, c)) or None + ot.util.launch_command = lambda kind, d, c, backend=None: ( + launches.append((kind, d, c, backend)) or None + ) os.environ["TMUX"] = "/tmp/tmux-1/default,1,0" app.session_stack = [] app.drill_into_session("ses_there") @@ -2216,7 +2389,7 @@ def test_launch_on_a_machine_with_no_ssh_target_offers_only_the_yank(): app.handle_key(None, ord("w")) # not offered -> ignored, menu stays open assert app.launch_menu is not None and not launches finally: - ot.util.tmux_launch = real_launch + ot.util.launch_command = real_launch if old_tmux is None: os.environ.pop("TMUX", None) else: diff --git a/tests/test_util.py b/tests/test_util.py index e16400c..6e6845c 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,8 +1,10 @@ """Clipboard/launchers, git-root folding, fuzzy match, range parsing, tool namespaces (util.py).""" import os +import subprocess import sys import tempfile +from types import SimpleNamespace import opentab as ot @@ -268,6 +270,277 @@ def test_tmux_launch_runs_the_hook_and_reports_its_stderr(): os.environ["OPENTAB_LAUNCHER"] = old_env +def test_in_herdr_only_accepts_an_enabled_official_marker(): + old = os.environ.get("HERDR_ENV") + try: + for value in (None, "", "0", "false", "no"): + if value is None: + os.environ.pop("HERDR_ENV", None) + else: + os.environ["HERDR_ENV"] = value + assert not ot.in_herdr() + os.environ["HERDR_ENV"] = "1" + assert ot.in_herdr() + os.environ["HERDR_ENV"] = "true" + assert ot.in_herdr() + finally: + if old is None: + os.environ.pop("HERDR_ENV", None) + else: + os.environ["HERDR_ENV"] = old + + +def test_herdr_create_argv_builds_focused_splits(): + assert ot.herdr_create_argv("hsplit", "/repo/a") == [ + "herdr", + "pane", + "split", + "--current", + "--direction", + "right", + "--cwd", + "/repo/a", + "--focus", + ] + assert ot.herdr_create_argv("vsplit", "/repo/a") == [ + "herdr", + "pane", + "split", + "--current", + "--direction", + "down", + "--cwd", + "/repo/a", + "--focus", + ] + for kind in ("popup", "unknown"): + try: + ot.herdr_create_argv(kind, "/repo/a") + except ValueError as exc: + assert "popup" in str(exc) if kind == "popup" else "unknown" in str(exc) + else: + raise AssertionError(f"accepted Herdr launch kind {kind}") + + old = os.environ.get("HERDR_BIN_PATH") + with tempfile.TemporaryDirectory() as tmp: + binary = os.path.join(tmp, "herdr-bin") + with open(binary, "w") as fh: + fh.write("#!/bin/sh\n") + os.chmod(binary, 0o755) + try: + os.environ["HERDR_BIN_PATH"] = binary + assert ot.herdr_create_argv("window", "/repo/a")[0] == binary + finally: + if old is None: + os.environ.pop("HERDR_BIN_PATH", None) + else: + os.environ["HERDR_BIN_PATH"] = old + + +def test_herdr_tab_create_argv_uses_workspace_when_set(): + old = os.environ.get("HERDR_WORKSPACE_ID") + try: + os.environ["HERDR_WORKSPACE_ID"] = "workspace-42" + assert ot.herdr_create_argv("window", "/repo/a") == [ + "herdr", + "tab", + "create", + "--workspace", + "workspace-42", + "--cwd", + "/repo/a", + "--focus", + ] + assert "--workspace" not in ot.herdr_create_argv("hsplit", "/repo/a") + finally: + if old is None: + os.environ.pop("HERDR_WORKSPACE_ID", None) + else: + os.environ["HERDR_WORKSPACE_ID"] = old + + +def test_herdr_tab_create_argv_omits_workspace_when_unset(): + old = os.environ.get("HERDR_WORKSPACE_ID") + try: + os.environ.pop("HERDR_WORKSPACE_ID", None) + assert ot.herdr_create_argv("window", "/repo/a") == [ + "herdr", + "tab", + "create", + "--cwd", + "/repo/a", + "--focus", + ] + finally: + if old is not None: + os.environ["HERDR_WORKSPACE_ID"] = old + + +def test_herdr_launch_reads_both_json_paths_and_keeps_command_one_argument(): + real_run = ot.util.subprocess.run + calls = [] + + def fake_run(argv, **kwargs): + calls.append((argv, kwargs)) + if len(calls) == 1: + key = "root_pane" if argv[1:3] == ["tab", "create"] else "pane" + return SimpleNamespace( + returncode=0, stdout=f'{{"result":{{"{key}":{{"pane_id":"pane-7"}}}}}}', stderr="" + ) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + try: + ot.util.subprocess.run = fake_run + command = "ssh -t box 'cd /repo/a && claude --resume abc'" + assert ot.herdr_launch("window", "/repo/a", command) is None + assert calls[1][0] == ["herdr", "pane", "run", "pane-7", command] + calls.clear() + assert ot.herdr_launch("hsplit", "/repo/a", command) is None + assert calls[1][0][-1] == command and len(calls[1][0]) == 5 + assert all("current" not in call[0][1:] for call in calls) + finally: + ot.util.subprocess.run = real_run + + +def test_herdr_launch_stops_on_create_and_json_errors(): + real_run = ot.util.subprocess.run + cases = [ + (SimpleNamespace(returncode=2, stdout="", stderr="socket gone"), "create failed"), + (SimpleNamespace(returncode=0, stdout="not json", stderr=""), "invalid JSON"), + (SimpleNamespace(returncode=0, stdout='{"result":{}}', stderr=""), "pane ID"), + ( + SimpleNamespace(returncode=0, stdout='{"result":{"pane":{"pane_id":4}}}', stderr=""), + "pane ID", + ), + ] + try: + for response, message in cases: + calls = [] + + def fake_run(argv, response=response, calls=calls, **kwargs): + calls.append(argv) + return response + + ot.util.subprocess.run = fake_run + error = ot.herdr_launch("hsplit", "/repo/a", "claude --resume abc") + assert message in error and len(calls) == 1 + assert not any(call[1:3] == ["pane", "current"] for call in calls) + finally: + ot.util.subprocess.run = real_run + + +def test_herdr_launch_reports_os_errors_timeouts_and_run_stage(): + real_run = ot.util.subprocess.run + try: + for failure, message in ( + (OSError("missing"), "create failed: missing"), + (subprocess.TimeoutExpired(["herdr"], 10), "create timed out"), + ): + + def fail_create(argv, failure=failure, **kwargs): + raise failure + + ot.util.subprocess.run = fail_create + assert message in ot.herdr_launch("window", "/repo/a", "cmd") + + for failure, message in ( + ( + SimpleNamespace(returncode=3, stdout="", stderr="rejected"), + "pane run failed: rejected", + ), + (OSError("closed"), "pane run failed for pane pane-9: closed"), + (subprocess.TimeoutExpired(["herdr"], 10), "pane run timed out for pane pane-9"), + ): + calls = 0 + + def fail_run(argv, failure=failure, **kwargs): + nonlocal calls + calls += 1 + if calls == 1: + return SimpleNamespace( + returncode=0, + stdout='{"result":{"root_pane":{"pane_id":"pane-9"}}}', + stderr="", + ) + if isinstance(failure, BaseException): + raise failure + return failure + + ot.util.subprocess.run = fail_run + error = ot.herdr_launch("window", "/repo/a", "cmd") + assert message in error and "pane-9" in error and calls == 2 + finally: + ot.util.subprocess.run = real_run + + +def test_launch_backend_prefers_hook_and_selects_innermost_multiplexer(): + keys = ("OPENTAB_LAUNCHER", "TMUX", "TMUX_PANE", "HERDR_ENV", "TERM") + old_env = {key: os.environ.get(key) for key in keys} + real_current, real_tmux = ot.util._current_tty, ot.util._tmux_pane_tty + with tempfile.TemporaryDirectory() as tmp: + hook = os.path.join(tmp, "launcher") + with open(hook, "w") as fh: + fh.write("#!/bin/sh\n") + os.chmod(hook, 0o755) + try: + for key in keys: + os.environ.pop(key, None) + assert ot.launch_backend() is None + os.environ["TMUX"] = "tmux" + assert ot.launch_backend() == "tmux" + os.environ.pop("TMUX") + os.environ["HERDR_ENV"] = "1" + assert ot.launch_backend() == "herdr" + os.environ["OPENTAB_LAUNCHER"] = hook + assert ot.launch_backend() == "hook" + os.environ.pop("OPENTAB_LAUNCHER") + os.environ["TMUX"] = "tmux" + os.environ["TMUX_PANE"] = "%1" + ot.util._current_tty = lambda: "/dev/pts/7" + ot.util._tmux_pane_tty = lambda: "/dev/pts/7" + assert ot.launch_backend() == "tmux" # tmux inside Herdr + ot.util._tmux_pane_tty = lambda: "/dev/pts/3" + assert ot.launch_backend() == "herdr" # Herdr inside tmux + ot.util._current_tty = lambda: None + os.environ["TERM"] = "screen-256color" + assert ot.launch_backend() == "tmux" + os.environ["TERM"] = "xterm-256color" + assert ot.launch_backend() == "herdr" + finally: + ot.util._current_tty, ot.util._tmux_pane_tty = real_current, real_tmux + for key, value in old_env.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def test_launch_command_dispatches_to_herdr_and_hook_popup_stays_async(): + real_backend = ot.util.launch_backend + real_herdr = ot.util.herdr_launch + real_hook = ot.util.launcher_hook + real_popen = ot.util.subprocess.Popen + calls = [] + try: + ot.util.launch_backend = lambda: "herdr" + ot.util.herdr_launch = lambda kind, directory, command: ( + calls.append((kind, directory, command)) or None + ) + assert ot.launch_command("window", "/repo/a", "cmd") is None + assert calls == [("window", "/repo/a", "cmd")] + + ot.util.launch_backend = lambda: "hook" + ot.util.launcher_hook = lambda: "/tmp/hook" + ot.util.subprocess.Popen = lambda argv, **kwargs: calls.append(argv) + assert ot.launch_command("popup", "/repo/a", "cmd") is None + assert calls[-1] == ["/tmp/hook", "popup", "/repo/a", "cmd"] + finally: + ot.util.launch_backend = real_backend + ot.util.herdr_launch = real_herdr + ot.util.launcher_hook = real_hook + ot.util.subprocess.Popen = real_popen + + def test_fuzzy_score_matches_subsequences(): assert ot.fuzzy_score("", "anything") == 0 # empty query matches everything assert ot.fuzzy_score("otb", "opentab") is not None # subsequence, not substring