hop is a project session manager built on Sway and Kitty.
Each project gets its own hop session - a dedicated Sway workspace identified by its working directory, holding the editor, terminals, and browser. hop also takes care of session lifecycle (prepare, teardown).
A hop session is conceptually similar to a tmux session, but with a fundamental difference: window management is left to the actual system window manager.
This alone has a few practical consequences:
- Single window manager - the same window manager covers session and non-session windows alike. Its normal shortcuts apply directly, with no second layered keymap or prefix key.
- GUI apps are part of the session - sessions can include browsers and other GUI apps, not just terminals.
- No multiplexer in the way - native terminal features work without lossy passthrough; system clipboard and scrollback are the real ones, not a copy-mode buffer.
Features
- Session terminals start in the session directory - spawn a shell anywhere in a session and it's already
cd-ed into the session root. - Dedicated session browser - to keep project-specific pages close to home.
- Dedicated editor - (Neovim by default) receives "open file from terminal output" sent via bundled Kitty plugin.
- Pluggable backends - shells and editor can run on the host, inside a Docker container, or anywhere describable as a chain of commands - without changing how you drive the session.
- Remote sessions over ssh - run any of those backends on a remote machine with
hop ssh; the same project config drives it whether you're local or remote. - Layouts - configure certain projects to start with extra windows (e.g. "server" or "console" for rails).
- Vicinae-driven workflow - sessions, windows, and switches surface as direct entries in the launcher's main search, turning hop into a true "zero new key bindings" solution.
- Scriptable - everything Vicinae dispatches to is also a
hopCLI subcommand.
Why does this exist?
hop started from a frustration: every project I work on accumulates browser tabs, while tmux sessions can only contain terminals. This really started getting out of hand once coding agents took hold.
On top of that, I've started doing more work in sandboxed environments, and tmux isn't great at managing sessions across different environments - it wants a tmux server everywhere.
Requirements
Optionally:
- Vicinae launcher
Installation
uv tool install git+https://github.com/artemave/hop
Or with pipx:
pipx install git+https://github.com/artemave/hop
Upgrading
After re-running uv tool install --reinstall . (or pipx upgrade hop), the running hopd is still the old version. Restart it to apply the upgrade:
hopd --restart
Usage
Some of the hop's features rely on a hopd daemon. Add this to your Sway config to ensure it's running:
exec hopd
Day-to-day, Vicinae is the primary surface. What you see when you type hop in Vicinae's main search depends on where you are:
- On a hop session's workspace (
s:<session>): one entry per declared window -Hop editor,Hop browser,Hop shell, etc. PlusHop killfor the focused session andHop switch to <other-session>for every other live session. - Off any hop workspace: only
Hop switch to <session>per live session. - Always:
Hop create session- falls through to a second Vicinae search over directories. Picking a directory creates a fresh session for it, or - if it's already the root of a hop session - switches to it.
Two complementary surfaces are described in their own sections below:
- Sway shortcuts - a key for "new shell in this session", faster than going through Vicinae for the most common action.
- Open visible-output targets from Kitty - a Kitty kitten that picks file paths and URLs from terminal output and routes them to the session's editor or browser.
Everything Vicinae's entries dispatch to is also reachable directly via the hop CLI (hop, hop switch <name>, hop move <name>, hop term --role <name>, hop open <target>, hop browser, hop kill) - useful for scripting and automation.
Sway shortcuts
Bind this helper script in your Sway config to spawn a new shell in the focused hop session (or a plain kitty when not on a hop workspace). Run hop path sway/term-or-kitty once and paste its output:
bindsym $mod+Return exec /printed/by/hop/path
Open visible-output targets from Kitty
Ctrl-Shift-O in any hop session window runs the visible-output picker. Change or disable the key with [keys].open_selection.
The picker scans visible terminal output and dispatches supported selections to the session editor or browser:
app/models/user.rbapp/models/user.rb:42b/app/models/user.rbhttps://example.comProcessing UsersController#index
File-shaped tokens that don't exist in the focused session's backend - including inside a devcontainer or on a remote ssh host - are not highlighted.
Hover links
The same targets also become hyperlinks under the mouse: hover one and click to dispatch it, no picker needed. Targets soft-wrapped across rows are linked as a whole. A hop link replaces any link the program printed itself (OSC 8) that overlaps it. Clicking any web URL in a session window - hop's link or not - opens it in the session browser, with the backend's localhost translation applied. In a program that captures the mouse (e.g. Claude Code's fullscreen UI), a plain click belongs to the program - shift+click a link instead.
This needs a kitty build whose watchers support on_mouse_move and screen.set_hyperlink_for_range() - upstream kitty has neither. hop registers the watcher in every session kitty regardless; kitty builds without these features never call it.
Binary files open on the host
Choosing a .png (or similar) in the open-selection kitten opens the file with your host's xdg-open instead of nvim - so PNGs land in your image viewer, PDFs in your reader, archives in your file manager, etc. This seamlessly works over ssh as well. Directories are the exception: they open in nvim.
Configuration
A hop config has several named sections plus a few scalar settings, all optional:
[backends.<name>]- a named backend. Keys:activate(auto-detect probe),prepare/teardown(lifecycle commands),port_translate/host_translate(rewritelocalhostURLs on dispatch),interactive_prefix/noninteractive_prefix(command wrappers). See Session backends.[layouts.<name>]- a named layout: anactivateprobe plus[layouts.<name>.windows.<role>]sub-tables that come up together when the probe matches. See Layouts and windows.[windows.<role>]- a top-level window. Keys:command,activate,position,env, and for the editoropen_keys/open_keys_with_line. See Layouts and windows.[keys]- session-kitty keybindings hop injects at bootstrap; each is a kitty key spec or a list of them, and an empty string or list disables it.paste(default["ctrl+v", "ctrl+shift+v"]) binds the clipboard-image paste kitten;open_selection(default["ctrl+shift+o"]) binds the visible-output picker.[clipboard]-allow_read(bool, defaulttrue) controls whether hop injects aclipboard_controloverride permitting OSC 52 clipboard reads without a per-paste prompt.sticky_positions(bool, defaulttrue) - global on/off switch for the sticky-positionfeature. See Layouts and windows.workspace_layout = "<mode>"- sway workspace layout applied at first session entry. One ofsplith,splitv,stacking,tabbed.debug_log = true- opt-in diagnostic log; see Troubleshooting.
Configs live in ~/.config/hop/config.toml or a project's .hop.toml.
Trusting .hop.toml
hop config runs shell commands on your behalf (e.g. activate or prepare). A project's .hop.toml can come with a repo someone else wrote, so it's untrusted by default: on first use hop asks whether to trust it (and can show it first), or you trust it upfront with hop trust. Changes to .hop.toml on a live session have no effect unless explicitly hop trusted again.
Session backends
A session has a backend that decides what kind of environment its terminals run in. The default is host. Other backends - docker container or anything else describable as a chain of commands - are configured as named entries in the config file. Running a backend on a remote machine is a separate axis - the ssh transport (hop ssh, see Remote sessions over ssh) - not a backend of its own.
Every window runs on the backend, not on the host - the editor included (unless the backend is the host). The one exception is the browser, which is always a host GUI app; see Special windows for what that implies for both.
Remote sessions over ssh
Run any project's session on a remote machine with hop ssh <host>: it sets up the ssh transport and drops you into a remote shell, where cd <project> && hop starts the session there. The project's own .hop.toml drives it - the same recipe runs a container locally or on the remote, with no ssh in the config and no local stub directory. {host} resolves to the remote (or localhost locally) for host-dependent values like LOCAL_HOSTNAME={host}. See docs/hop-ssh.md for the usage guide (and troubleshooting, e.g. raising sshd MaxSessions).
Auto-detection
When you enter a session (bare hop), hop walks the configured backends in declaration order and runs each backend's activate probe in the session root. The first one that exits 0 wins. If none succeed, the session falls back to host. The chosen backend is persisted and reused for all subsequent commands against that session.
Backend example
[backends.devcontainer]
activate = "test -f docker-compose.dev.yml"
prepare = [
"podman-compose -f docker-compose.dev.yml --in-pod=false up -d devcontainer",
"""curl -fsSL https://github.com/kovidgoyal/kitty/releases/latest/download/kitten-linux-amd64 \\
| podman-compose -f docker-compose.dev.yml exec -T devcontainer \\
sudo install -m 755 /dev/stdin /usr/local/bin/kitten""",
]
teardown = "podman-compose -f docker-compose.dev.yml down"
port_translate = """
podman ps -q \\
--filter label=io.podman.compose.project=$(basename {session_root}) \\
--filter label=io.podman.compose.service=devcontainer \\
| head -1 \\
| xargs -r -I@ podman port @ {port} \\
| cut -d: -f2
"""
interactive_prefix = "podman-compose -f docker-compose.dev.yml exec devcontainer"
noninteractive_prefix = "podman-compose -f docker-compose.dev.yml exec -T devcontainer"
Each lifecycle / translate field is either a single string or a list of strings. Single-string values run as one sh -c invocation; list values run each element as its own sh -c invocation in declaration order. For prepare and teardown the sequence aborts on the first non-zero exit (the popup's held shell shows the failing step). For port_translate / host_translate the last element's stripped stdout is the translated value (earlier elements run for their side effects). Use TOML triple-quoted strings ("""…""") for multi-line pipelines inside any element. Placeholder values are shell-quoted before insertion, so paths with spaces substitute safely. interactive_prefix and noninteractive_prefix stay string-only - they wrap, they don't sequence.
Backend fields:
activate(optional) - auto-detect probe. Backends withoutactivatecan only be picked by name.prepare(optional, string or list) - command(s) run once at session creation, before launching kitty. Should be idempotent. List form runs steps sequentially and aborts on the first failure.teardown(optional, string or list) - command(s) run athop killafter closing windows.port_translate(optional, string or list) - command(s) run lazily by thekitten/hintskitten when it dispatches alocalhost/127.0.0.1/0.0.0.0/*.localhostURL. The last step's stdout is the host-reachable port that should replace the URL's port.{port}is substituted with the URL's original port.host_translate(optional, string or list) - command(s) run lazily for the same set of localhost URLs. The last step's stdout is the hostname that should replace the URL's host (e.g.echo platform.localhostfor an app routed by subdomain).interactive_prefix(required) - shell snippet prepended to every window command launched in this backend's environment. Empty for the implicit host backend.noninteractive_prefix(required) - prefix hop uses for non-interactive backend operations like file-existence checks. Backends that allocate a TTY by default (podman-compose exec) must set the no-TTY variant (e.g.... exec -T devcontainer); backends that don't (ssh) pass the same string asinteractive_prefix. The implicithostbackend ships with both prefixes set to""(empty).
Supported placeholders: {session_root} (anywhere), and {port} (in port_translate / host_translate only).
The name host is reserved for the implicit fallback.
Layouts and windows
Per-role launch commands live outside the backend, in [layouts.<name>] or [windows.<role>] tables:
[layouts.rails]
activate = "test -f bin/rails"
[layouts.rails.windows.server]
command = "bin/dev"
[layouts.rails.windows.console]
command = "bin/rails console"
activate = "false"
# Top-level window
[windows.worker]
command = "bin/jobs"
The active backend's interactive_prefix wraps each window's command at launch, so the same Rails layout works in both a host session (runs bin/dev directly) and a devcontainer session (runs podman-compose exec devcontainer bin/dev).
Per-window fields:
-
command(string) - the role command, without any backend wrap. It's typed into the role's session shell, so it lands in shell history and the window stays a usable shell after the command exits. An empty string (e.g.[layouts.rails.windows.test] command = "") is just that bare shell. -
activate(string, optional) - shell probe; the window auto-launches when it exits 0. Defaults to"true". -
position(integer, optional) - sticks the window to a slot in the row of windows on the session's workspace. Positive counts from the left (1= leftmost), negative counts from the right (-1= rightmost). Unpositioned windows fill the middle, in creation order:[windows.shell] position = 1 [windows.server] command = "bin/dev" position = -1With this config,
shellalways opens leftmost andserveralways opens rightmost; any other window (declared or ad hoc, like theshell-2spawned by re-runninghopfrom inside the session) opens between them. Only the newly-created window is repositioned - existing windows keep their relative order. The session browser participates in the same row as the kitty role terminals, even though it isn't a kitty window.Sticky positioning is on by default: shell, editor, and browser ship with built-in positions
1,2, and3respectively, so a fresh session always lays out shell, editor, and browser leftmost-to-rightmost in that order, ahead of any other window (any additional window without an explicit position lands after all three, since unpositioned windows fill the middle bucket, which always sorts after every positive position). Set the top-levelsticky_positions = falseto turn the whole feature off - every position, built-in or explicitly configured, is then ignored and Sway's own tiling placement is left alone:sticky_positions = false -
env(table, optional) - extra environment variables for that window. They're exported inside the backend - in the container or on the remote host - before the window's shell starts, so the role'scommandsees them too. Not available onbrowser.[layouts.rails.windows.server] command = "bin/dev" env = { PORT = "3001", PATH = "$HOME/.local/bin:$PATH", GREETING = "hello there" }Each value behaves like the inside of a shell double-quoted string: spaces and quotes are kept as written,
$VARand$(...)expand, and a backslash keeps a$literal (PRICE = '\$5').{session_root}and{host}substitute too. When several entries for the same role setenv, the tables are combined variable by variable, following the same precedence as other fields. Ad-hoc shells (shell-2, ...) get theshellrole'senv.
A session that outlived its windows - after hop kill, a crash, or a reboot - re-activates every window on the next hop rather than coming back half-empty.
Built-in roles shell, editor, and browser ship with hop defaults:
| role | command default | activate default | position default |
|---|---|---|---|
| shell | login shell | active | 1 |
| editor | nvim |
active | 2 |
| browser | xdg-detected default browser | inactive | 3 |
To change a built-in, declare it as a top-level window: [windows.editor] activate = "false" opts out of the editor for this config; [windows.browser] activate = "true" activates the browser; [windows.shell] command = "/usr/bin/zsh" overrides the shell. The editor and the browser carry extra fields and behavior of their own - see Special windows.
Kitty shell integration (OSC 133 prompt marks, which power hop wait and other OSC-133-dependent features) is automatic - no shell-role config needed. Inside a container backend, kitten must be installed in the container - add an install step to the backend's prepare (see devcontainer); without it the shell still opens but prints a one-line warning that integration is off. For a remote host, hop ssh handles it (see hop ssh). To use a different shell, override the built-in: [windows.shell] command = "/usr/bin/fish".
Multiple matching layouts compose: a Rails project that also has vite.config.ts activates both layouts and gets their windows.
When several entries declare the same role, they combine field by field, and for a field set in more than one the more specific entry wins, from weakest to strongest:
- global
[windows.<role>] - global
[layouts.<name>.windows.<role>](when the layout matches) - project
[windows.<role>] - project
[layouts.<name>.windows.<role>](when the layout matches)
So a generic [windows.server] command = "bin/dev" can be overridden by a Rails layout's server, and a project's .hop.toml overrides anything in the global config.
A window command is typed into the role's shell as if you ran it
