Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <that machine> 'cd <its project> && …'` (`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
<that machine> 'cd <its project> && …'` (`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 <machine>`** drops one
Expand Down Expand Up @@ -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)) |
Expand Down
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <target> '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 <target> '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
Expand Down
10 changes: 9 additions & 1 deletion docs/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/opentab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 33 additions & 24 deletions src/opentab/tui/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""App: state and the keyboard/mouse state machine."""

from __future__ import annotations

import argparse
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)":
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/opentab/tui/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
context_compactions,
context_size,
fuzzy_score,
launcher_hook,
tool_namespace,
unicode_screen,
)
Expand Down Expand Up @@ -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 = [
Expand Down
Loading
Loading