From 99418355b8a75899e79a00700b5b6030490affe5 Mon Sep 17 00:00:00 2001 From: Simon Eisenhauer Date: Wed, 5 Aug 2026 06:05:48 +0200 Subject: [PATCH] feat(launch): add herdr session launcher --- README.md | 9 +- docs/architecture.md | 13 ++ docs/keys.md | 2 +- docs/privacy.md | 10 +- src/opentab/__init__.py | 6 + src/opentab/tui/app.py | 57 +++--- src/opentab/tui/renderer.py | 7 +- src/opentab/util.py | 214 ++++++++++++++++++++- tests/__init__.py | 12 ++ tests/test_doctor.py | 4 + tests/test_tui_app.py | 268 +++++++++++++++++++++++++-- tests/test_util.py | 357 ++++++++++++++++++++++++++++++++++++ 12 files changed, 900 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index b2ed327..2890111 100644 --- a/README.md +++ b/README.md @@ -199,9 +199,10 @@ Once pulled, the fleet behaves like any other harness: - **`M`** filters every view to one machine — the harness picker (`H`)'s twin; Trends and the web browser gain a per-machine breakdown. -- **`L`** on a pulled session reopens it **on the box it ran on**: the launch targets wrap - the resume command in `ssh -t 'cd && …'` (`y` yanks the same - line), because the session id and the path belong over there, not here. +- **`L`** on a pulled session reopens it **on the box it ran on** only when that machine has + an SSH target in `remotes.json`: the launch targets wrap the resume command in `ssh -t + 'cd && …'` (`y` yanks the same line). URL-based pulls have no + SSH target and therefore offer only the copied command. - **`opentab remote`** reopens the last pull offline, with no SSH round-trip. - **`opentab export box.json`** writes one box's summary by hand (so `ssh box opentab export > box.json` works), and **`opentab forget `** drops one @@ -229,7 +230,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 only when its `remotes.json` entry has an SSH target; URL-based pulls offer only the copied command | | `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..44c21e2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,6 +138,19 @@ 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 --pane "$HERDR_PANE_ID"`, deliberately binding the split to the +current Herdr pane rather than using `--current` or `pane.current`. 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. `herdr pane run` internally uses the input +semantics of `pane.send_input`, but OpenTab only invokes the CLI and never opens or uses a +Herdr socket directly. It does not use the socket operations `layout.apply` or +`pane.current`. 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..2705d07 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 only when it provides a valid `HERDR_PANE_ID` for the current pane; otherwise it offers only the tab and copy. 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* only when its `remotes.json` entry has an SSH target: every available target wraps the command in `ssh -t 'cd … && …'`, and `y` yanks that same line. A 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..896020a 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -60,11 +60,19 @@ 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`. `herdr pane run` internally uses the +same input semantics as `pane.send_input`. OpenTab cannot create or control popups through +Herdr's general pane CLI/API, so it does not offer them; it only uses the CLI and never +opens a Herdr socket. It does not call the socket operations `layout.apply` or +`pane.current`. + ## 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..74ad997 100644 --- a/src/opentab/__init__.py +++ b/src/opentab/__init__.py @@ -205,7 +205,13 @@ est_tokens, fuzzy_score, git_root, + herdr_create_argv, + herdr_launch, + herdr_pane_id, + 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..04fe322 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,26 @@ 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 its external popups are not controllable. 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") + if util.herdr_pane_id() is None: + targets = tuple( + target for target in targets if target[1] not in ("hsplit", "vsplit") + ) + 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 +3965,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): @@ -3997,6 +4002,7 @@ def handle_launch_key(self, key: int | str) -> bool: return True if act == "cancel": self.launch_menu = None + self.launch_menu_backend = None self.notice = "launch cancelled" return True # The per-target letters follow the target names (w/s/v/p/y today), so they @@ -4008,11 +4014,13 @@ def handle_launch_key(self, key: int | str) -> bool: index = self.launch_menu_index % n else: return True # ignore unknown keys, keep the modal open - session, self.launch_menu = self.launch_menu, None - self._do_launch(session, targets[index][1]) + session, backend = self.launch_menu, self.launch_menu_backend + self.launch_menu = None + self.launch_menu_backend = None + self._do_launch(session, targets[index][1], backend) return True - def _do_launch(self, session: Workflow, kind: str) -> None: + def _do_launch(self, session: Workflow, kind: str, backend: str | None) -> None: if kind == "copy": self.copy_resume_command(session) return @@ -4021,7 +4029,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, backend) if error: self.notify(f"launch failed: {error}", "error") else: @@ -6809,6 +6817,7 @@ def handle_mouse(self) -> bool: if self.launch_menu is not None: if click or double: self.launch_menu = None # click cancels the launch picker + self.launch_menu_backend = None return True if self.toast_history: # The notices scrollback is drawn over the whole body, but it had no mouse diff --git a/src/opentab/tui/renderer.py b/src/opentab/tui/renderer.py index 12f0652..e71df9a 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, ) @@ -5804,9 +5803,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..d6413f2 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 @@ -82,7 +84,7 @@ def palette_writes_ignored() -> bool: against herdr 0.7.5: `ghostty_cell_color()` maps `CellColor::Palette(i)` to `Color::Indexed(i)`, and the resolved palette is read only to answer queries. """ - return env_flag("HERDR_ENV") is True + return in_herdr() # Terminal multiplexers, each keyed on a marker the program sets ITSELF -- the same @@ -121,7 +123,7 @@ def terminal_multiplexers() -> list[str]: def running(name: str, var: str) -> bool: if name == "herdr": - return env_flag(var) is True # match palette_writes_ignored exactly + return in_herdr() # match palette_writes_ignored exactly return bool(os.environ.get(var)) found = [name for name, var in _MULTIPLEXERS if running(name, var)] @@ -642,6 +644,177 @@ 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 herdr_pane_id() -> str | None: + """The current Herdr pane ID, normalized, or None when it is unusable.""" + pane = os.environ.get("HERDR_PANE_ID") + if pane is None: + return None + pane = pane.strip() + return pane or None + + +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": + pane = herdr_pane_id() + if pane is None: + raise ValueError("HERDR_PANE_ID is required for Herdr splits") + return [ + herdr, + "pane", + "split", + "--pane", + pane, + "--direction", + "right", + "--cwd", + directory, + "--focus", + ] + if kind == "vsplit": + pane = herdr_pane_id() + if pane is None: + raise ValueError("HERDR_PANE_ID is required for Herdr splits") + return [ + herdr, + "pane", + "split", + "--pane", + pane, + "--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 = (getattr(proc, "stderr", "") or "").strip() + # Herdr writes successful responses as JSON. Do not put a failed response's raw + # JSON in a toast either; stderr is the CLI's human-facing diagnostic. + try: + json.loads(detail) + except (TypeError, json.JSONDecodeError): + if detail: + return f"herdr {stage} failed: {detail.splitlines()[0][:200]}" + return f"herdr {stage} failed (exit status {getattr(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.""" + try: + return _herdr_cli_launch(kind, directory, command) + except ValueError as exc: + return str(exc) + + 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 +876,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 +887,38 @@ 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() + detail = (proc.stderr or "").strip() + return detail or f"{label} failed" 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": + return herdr_launch(kind, directory, command) + 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..6341584 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -32,6 +32,18 @@ ): 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_PANE_ID", + "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_doctor.py b/tests/test_doctor.py index 6d20ebe..8e7bd40 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -34,6 +34,7 @@ "ZELLIJ", "DVTM", "BYOBU_BACKEND", + "TERM", "SHELL", "POWERSHELL_DISTRIBUTION_CHANNEL", "PSModulePath", @@ -46,6 +47,9 @@ def _clean_env(**over): try: for key in _ENV_KEYS: os.environ.pop(key, None) + # Doctor correctly treats an absent TERM as a terminal failure. Give ordinary + # report tests the same usable terminal GitHub Actions does not provide. + os.environ["TERM"] = "xterm-256color" for key, value in over.items(): os.environ[key] = value yield diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 1b4f77c..a7471ce 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 @@ -2077,21 +2078,29 @@ 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_backend, real_launch, real_copy = ( + ot.util.launch_backend, + 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_backend = lambda: "tmux" if os.environ.get("TMUX") else 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 app.launch_menu_backend is None + 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) - assert len(launches) == 1 and "cancelled" in app.notice + assert len(launches) == 1 and "cancelled" in app.notice and app.launch_menu_backend is None # y inside the menu copies the cd-prefixed command app.handle_key(None, ord("L")) app.handle_key(None, ord("y")) @@ -2109,7 +2118,8 @@ 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_backend = real_backend + ot.util.launch_command = real_launch ot.util.copy_to_clipboard = real_copy if old_tmux is None: os.environ.pop("TMUX", None) @@ -2117,6 +2127,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" @@ -2124,10 +2162,13 @@ 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_backend, real_launch = ot.util.launch_backend, ot.util.launch_command launches = [] try: - ot.util.tmux_launch = lambda kind, d, c: launches.append((kind, d, c)) or None + ot.util.launch_backend = lambda: "tmux" + 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" @@ -2141,15 +2182,136 @@ 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 app.launch_menu_backend is None + assert launches[0][:3] == ("hsplit", "/repo/a", "claude --resume ses_1") finally: - ot.util.tmux_launch = real_launch + ot.util.launch_backend = real_backend + 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 + old_pane = os.environ.get("HERDR_PANE_ID") + launches, copies = [], [] + try: + os.environ["HERDR_PANE_ID"] = "pane-42" + 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")) + assert app.launch_menu_backend is None + 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 + if old_pane is None: + os.environ.pop("HERDR_PANE_ID", None) + else: + os.environ["HERDR_PANE_ID"] = old_pane + + +def test_herdr_launch_menu_without_pane_id_offers_only_tab_and_copy(): + 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 + old_pane = os.environ.get("HERDR_PANE_ID") + try: + ot.util.launch_backend = lambda: "herdr" + for pane_id in (None, "", " "): + if pane_id is None: + os.environ.pop("HERDR_PANE_ID", None) + else: + os.environ["HERDR_PANE_ID"] = pane_id + app.handle_key(None, ord("L")) + assert [kind for _key, kind, _label in app.launch_targets()] == ["window", "copy"] + app.handle_key(None, 27) + finally: + ot.util.launch_backend = real_backend + if old_pane is None: + os.environ.pop("HERDR_PANE_ID", None) + else: + os.environ["HERDR_PANE_ID"] = old_pane + + +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 app.launch_menu_backend 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`. @@ -2172,6 +2334,66 @@ 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", "OPENTAB_LAUNCHER") + } + real_current = ot.util._current_tty + real_tmux = ot.util._tmux_pane_tty + real_hook = ot.util.launcher_hook + try: + os.environ.pop("OPENTAB_LAUNCHER", None) + ot.util.launcher_hook = lambda: None + 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 + ot.util.launcher_hook = real_hook + 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 @@ -2180,14 +2402,21 @@ 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_backend, real_launch, real_copy = ( + ot.util.launch_backend, + 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_backend = lambda: "tmux" + 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" @@ -2212,11 +2441,12 @@ 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_backend = real_backend + ot.util.launch_command, ot.util.copy_to_clipboard = real_launch, real_copy if old_tmux is None: os.environ.pop("TMUX", None) else: @@ -2229,10 +2459,13 @@ 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_backend, real_launch = ot.util.launch_backend, ot.util.launch_command launches = [] try: - ot.util.tmux_launch = lambda kind, d, c: launches.append((kind, d, c)) or None + ot.util.launch_backend = lambda: "tmux" + 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") @@ -2250,7 +2483,8 @@ 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_backend = real_backend + 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..80eabe7 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 @@ -261,6 +263,12 @@ def test_tmux_launch_runs_the_hook_and_reports_its_stderr(): with open(hook, "w") as fh: fh.write('#!/bin/sh\necho "no such kind" >&2\nexit 1\n') assert ot.util.tmux_launch("vsplit", "/repo/a", "claude --resume x1") == "no such kind" + with open(hook, "w") as fh: + fh.write('#!/bin/sh\nprintf " \\n" >&2\nexit 1\n') + assert ( + ot.util.tmux_launch("vsplit", "/repo/a", "claude --resume x1") + == "launcher hook failed" + ) finally: if old_env is None: os.environ.pop("OPENTAB_LAUNCHER", None) @@ -268,6 +276,355 @@ 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(): + old_pane = os.environ.get("HERDR_PANE_ID") + try: + os.environ["HERDR_PANE_ID"] = " pane-42 " + assert ot.herdr_create_argv("hsplit", "/repo/a") == [ + "herdr", + "pane", + "split", + "--pane", + "pane-42", + "--direction", + "right", + "--cwd", + "/repo/a", + "--focus", + ] + assert ot.herdr_create_argv("vsplit", "/repo/a") == [ + "herdr", + "pane", + "split", + "--pane", + "pane-42", + "--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}") + finally: + if old_pane is None: + os.environ.pop("HERDR_PANE_ID", None) + else: + os.environ["HERDR_PANE_ID"] = old_pane + + +def test_herdr_splits_require_the_current_pane_id(): + old_pane = os.environ.pop("HERDR_PANE_ID", None) + try: + for pane_id in (None, "", " \t "): + if pane_id is None: + os.environ.pop("HERDR_PANE_ID", None) + else: + os.environ["HERDR_PANE_ID"] = pane_id + for kind in ("hsplit", "vsplit"): + try: + ot.herdr_create_argv(kind, "/repo/a") + except ValueError as exc: + assert str(exc) == "HERDR_PANE_ID is required for Herdr splits" + else: + raise AssertionError(f"accepted {kind} with HERDR_PANE_ID={pane_id!r}") + assert ( + ot.herdr_launch(kind, "/repo/a", "cmd") + == "HERDR_PANE_ID is required for Herdr splits" + ) + finally: + if old_pane is not None: + os.environ["HERDR_PANE_ID"] = old_pane + + +def test_herdr_create_argv_uses_configured_binary(): + 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") + old_pane = os.environ.get("HERDR_PANE_ID") + try: + os.environ["HERDR_WORKSPACE_ID"] = "workspace-42" + os.environ["HERDR_PANE_ID"] = "pane-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 + if old_pane is None: + os.environ.pop("HERDR_PANE_ID", None) + else: + os.environ["HERDR_PANE_ID"] = old_pane + + +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 + old_pane = os.environ.get("HERDR_PANE_ID") + 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: + os.environ["HERDR_PANE_ID"] = "pane-current" + 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 calls[0][0][3:5] == ["--pane", "pane-current"] + finally: + ot.util.subprocess.run = real_run + if old_pane is None: + os.environ.pop("HERDR_PANE_ID", None) + else: + os.environ["HERDR_PANE_ID"] = old_pane + + +def test_herdr_launch_stops_on_create_and_json_errors(): + real_run = ot.util.subprocess.run + old_pane = os.environ.get("HERDR_PANE_ID") + 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: + os.environ["HERDR_PANE_ID"] = "pane-current" + 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 + if old_pane is None: + os.environ.pop("HERDR_PANE_ID", None) + else: + os.environ["HERDR_PANE_ID"] = old_pane + + +def test_herdr_failures_use_stderr_but_never_raw_json_output(): + real_run = ot.util.subprocess.run + try: + for stderr, stdout, expected in ( + ( + "permission denied\nmore detail", + '{"error":"no"}', + "herdr create failed: permission denied", + ), + (" \n", '{"error":"no"}', "herdr create failed (exit status 2)"), + ('{"error":"no"}', "", "herdr create failed (exit status 2)"), + ): + ot.util.subprocess.run = lambda argv, stderr=stderr, stdout=stdout, **kwargs: ( + SimpleNamespace(returncode=2, stderr=stderr, stdout=stdout) + ) + error = ot.herdr_launch("window", "/repo/a", "cmd") + assert error == expected and '{"error"' not in error + 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", "XDG_CONFIG_HOME") + 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) + # A real config launcher must not leak into backend selection; the explicit + # OPENTAB_LAUNCHER below still has priority. + os.environ["XDG_CONFIG_HOME"] = os.path.join(tmp, "empty-config") + 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