Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)) |
Expand Down
10 changes: 10 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 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 <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 |
| `e` | Export the current list to a CSV in the working directory |

## Views & overlays
Expand Down
7 changes: 6 additions & 1 deletion docs/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/opentab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 22 additions & 21 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,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
Expand All @@ -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):
Expand Down Expand Up @@ -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:
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 @@ -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 = [
Expand Down
Loading
Loading