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 open for it. Moving between sessions is a single jump. hop also takes care of session lifecycle (prepare, teardown).
A hop session is conceptually similar to a tmux session, but with a fundamentally different architecture: decoupled session and window management. hop separates two concerns that terminal multiplexers fundamentally couple together, delegating window management to the actual system window manager.
This alone has a few practical consequences:
cd-ed into the session root.hop ssh; the same project config drives it whether you're local or remote.hop CLI subcommand.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.
Optionally:
uv tool install git+https://github.com/artemave/hop
Or with pipx:
pipx install git+https://github.com/artemave/hop
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
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:
s:<session>): one entry per declared window - Hop editor, Hop browser, Hop shell, etc. Plus Hop kill for the focused session and Hop switch to <other-session> for every other live session.Hop switch to <session> per live session.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:
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.
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
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#indexFile-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.
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.
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 (rewrite localhost URLs on dispatch), interactive_prefix / noninteractive_prefix (command wrappers). See Session backends.[layouts.<name>] - a named layout: an activate probe 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, and for the editor open_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, default true) controls whether hop injects a clipboard_control override permitting OSC 52 clipboard reads without a per-paste prompt.workspace_layout = "<mode>" - sway workspace layout applied at first session entry. One of splith, 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.
.hop.tomlAs project's .hop.toml can declare shell commands (activate, prepare, teardown, translate helpers, window command, open_keys) that hop runs on your behalf. hop only runs a project's .hop.toml once you've trusted it with hop trust. Changes to .hop.toml on a live session have no effect unless explicitly hop trusted again.
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.
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).
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.
[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 without activate can 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 at hop kill after closing windows.port_translate (optional, string or list) - command(s) run lazily by the kitten/hints kitten when it dispatches a localhost / 127.0.0.1 / 0.0.0.0 URL. 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 localhost / 127.0.0.1 / 0.0.0.0 in the URL.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 as interactive_prefix. The implicit host backend 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.
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".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 |
|---|---|---|
| shell | login shell | active |
| editor | nvim | active |
| browser | xdg-detected default browser | inactive |
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.
A window command is typed into the role's shell as if you ran it there - so it lands in the shell's history (up-arrow re-runs it after a Ctrl-C) and any shell syntax works as written. Make a long-running role idempotent - survive a window close and reopen - by freeing its resource before it starts:
[layouts.rails.windows.server]
command = "fuser -k 3000/tcp 2>/dev/null; bin/dev"
The command runs in the same namespace as the session - inside the container for a devcontainer backend, on the remote over ssh - so use whatever the image has for the cleanup: pkill -f bin/dev, lsof -ti:3000 | xargs -r kill, etc.
hop --backend <name>
Forces a backend at session creation regardless of auto-detect. Use hop --backend host to keep the host backend in a project that would otherwise auto-activate something else. The choice is persisted for the session's lifetime.
Most roles are interchangeable - a kitty window running the session shell with the role's command typed into it. The editor and the browser are not: they are dispatch targets that hop open and the Kitty kitten route files and URLs to, and each carries extra fields and behavior of its own.
A session has exactly one editor window, shared by everything that opens a file into it. It's still an ordinary role terminal underneath - a shell with nvim typed in - so hop term --role editor creates it on first use and focuses it afterwards.
[windows.editor] activate = "false" opts out for a config.[windows.editor] command = "helix" swaps it - along with the keystroke templates that drive file-open dispatch, which are written for vim by default.hop open <file>[:<line>], a Rails Controller#action ref, and any file token picked by the Kitty kitten. Binary files are the exception: they open on the host, see Binary files open on the host.Pasting a clipboard image. Ctrl-V (and Ctrl-Shift-V) in any hop session window writes a clipboard image into the focused window's filesystem - inside the container or on the remote host when that's where the window runs - and pastes its path. Claude Code and Codex attach the file it points at; a plain shell or editor just receives the path. Anything other than an image falls through to kitty's native paste. If the paste fails or times out (e.g. a dropped ssh link), a one-line notice appears in the window.
The host needs wl-clipboard (wl-paste) installed for image paste. Change or disable the keys with [keys].paste. No kitty.conf changes are needed.
With nvim on a remote host or inside a container, point its clipboard provider at OSC 52 whenever no display is present:
if empty($WAYLAND_DISPLAY) && empty($DISPLAY)
let g:clipboard = 'osc52'
endif
Copy works with that alone. Paste ("+p) also works out of the box: hop allows OSC 52 clipboard reads in the session kitty ([clipboard].allow_read, default true), so nvim isn't prompted on every paste. The trade-off is that any program in the session's kitty can then read the system clipboard. Set allow_read = false to opt out and take the per-paste prompt back.
hop open <file>[:<line>] and the kitten/hints dispatch path drive the editor by writing raw bytes into its kitty window. Two [windows.editor] fields let you swap the byte sequence for any TUI editor:
open_keys - template used when the target has no line number.open_keys_with_line - template used when the target has a line number.Both are Python str.format templates. {path} substitutes the target path (with any literal ' doubled, see below); {line} substitutes the decimal line number.
Defaults reproduce vim/nvim's :drop fnameescape(...) exactly:
DEFAULT_OPEN_KEYS = "\x1b:exec 'drop '.fnameescape('{path}')\r"
DEFAULT_OPEN_KEYS_WITH_LINE = DEFAULT_OPEN_KEYS + ":{line}\r"
Pointing hop at helix:
[windows.editor]
command = "helix"
open_keys = "\u001b:open {path}\r"
open_keys_with_line = "\u001b:open {path}:{line}\r"
TOML basic strings disallow literal control bytes, so Escape has to be written as \u001b - TOML only defines \b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX. Reading the helix template: \u001b drops the editor out of insert mode, : enters command mode, open {path} is the open-file command, \r submits.
Literal single quotes in {path} are doubled before formatting, so a template that embeds {path} inside a single-quoted string (as the nvim default does) handles paths containing '. Templates that don't wrap {path} in '...' are unaffected.
The browser is the one window that isn't a kitty terminal. hop doesn't own the process - it launches (or adopts) a window of your existing default browser and tracks it with a Sway mark, so tabs, profile, and extensions are the ones you already have.
[windows.browser] activate = "true" turns it on for a config.[windows.browser] command = "..." overrides the detection.hop open <url> runs a URL through the backend's port_translate / host_translate first, so http://localhost:3000 inside the container becomes the host-reachable address.hop browser [<url>] focuses the session's browser window, creating it if there is none, and moves it back onto s:<session> if it drifted. With a URL, the URL goes to that window.A browser restart (relaunching after an update, or a reboot) loses the hop affiliation of every window; the browser's own session restore brings your tabs back as ordinary windows.
hop picks them back up. When a session has no marked browser window, hop browser adopts an unclaimed browser window already on s:<session> instead of launching a second one next to your restored tabs. After a restart, move the restored window onto the session's workspace and run hop browser - it marks it, focuses it, and dispatches URLs to it from then on. A window already marked for another session is never taken, so a browser window on another workspace stays yours.
Moving the session browser off s:<session> with raw Sway commands clears its mark; moving it back re-adopts it on the next hop browser.
The commands below are the integration surface for external tools. hop runs on the host; using the CLI from inside a container backend requires a shim (see docs/devcontainer.md).
hop run and hop waithop run "ls"
hop run --role test "python3 -m pytest -q"
hop run --role server "bin/dev"
hop run --role server --focus "bin/dev"
The command must be a single CLI argument. The default role is shell. hop run dispatches the command, prints an opaque run id, and returns immediately - it does not wait for completion.
By default hop run keeps the current focus. Pass --focus to focus the role terminal and switch Sway to the session's workspace.
id=$(hop run --role test "python3 -m pytest -q")
hop wait "$id"
hop wait blocks until the dispatched command returns to its shell prompt, then writes its output to stdout and exits with the command's own exit status. It exits 124 if it gives up first (10 minutes by default). This two-step protocol is what vigun uses to send a test run from the editor to a dedicated terminal in the session and collect its result once the run finishes.
hop list - print active Sway workspaces whose names start with s:.hop switch <name> - focus the Sway workspace s:<name>.hop move <name> - move the currently focused Sway window onto s:<name> and switch to that workspace.hop open <target> - route the target to the right place: a URL goes to the session browser (with the backend's localhost translation applied), a binary file (image, PDF, archive, ...) opens on the host with xdg-open, a Rails Controller#action ref or path[:line] goes to the session editor. See Binary files open on the host. The kitten under Open visible-output targets from Kitty uses the same parser.hop term --role <name> - focus or create the window for the given role, editor included.hop browser [<url>] - focus or create the session's browser window, and send it a URL if given.hop kill - close every Sway/Kitty window owned by the session, remove its workspace, and run the backend's teardown. Run from the session root.hop trust - trust the current directory's .hop.toml and, if the session is already live, refresh the trusted snapshot. hop trust --list shows every trusted config and flags drift; hop trust --revoke [path] drops one. See Trusting .hop.toml.Two log files help when something goes wrong:
debug_log (opt-in) - set debug_log = true in the config to append a diagnostic log of backend command runs (prepare / teardown / translate / auto-detect probes) and kitty bootstrap stdio to $XDG_RUNTIME_DIR/hop/debug.log. Set to a string to use a custom path. First place to look when hop fails silently - especially when launched from Vicinae, where stderr is not visible.prepare / teardown popup streams its terminal output to $XDG_RUNTIME_DIR/hop/popup-<session>-<kind>.log (one file per session and kind, overwritten each run). cat $XDG_RUNTIME_DIR/hop/popup-myproj-prepare.log shows exactly what the last prepare run printed - the place to look when a prepare script misbehaves and the popup closed before you could read it.In-depth guides live under docs/:
docs/devcontainer.md - step-by-step devcontainer backend setup and troubleshooting.docs/hop-ssh.md - running a session on a remote machine with hop ssh.docs/vigun.md - the hop run / hop wait contract behind the vigun editor integration.docs/ssh.md and docs/ssh-devcontainer.md - the hand-wired ssh recipes that hop ssh supersedes.uv sync
make # to run tests and lints
237 commits
Python
99.8%
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 open for it. Moving between sessions is a single jump. hop also takes care of session lifecycle (prepare, teardown).
A hop session is conceptually similar to a tmux session, but with a fundamentally different architecture: decoupled session and window management. hop separates two concerns that terminal multiplexers fundamentally couple together, delegating window management to the actual system window manager.
This alone has a few practical consequences:
cd-ed into the session root.hop ssh; the same project config drives it whether you're local or remote.hop CLI subcommand.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.
Optionally:
uv tool install git+https://github.com/artemave/hop
Or with pipx:
pipx install git+https://github.com/artemave/hop
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
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:
s:<session>): one entry per declared window - Hop editor, Hop browser, Hop shell, etc. Plus Hop kill for the focused session and Hop switch to <other-session> for every other live session.Hop switch to <session> per live session.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:
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.
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
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#indexFile-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.
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.
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 (rewrite localhost URLs on dispatch), interactive_prefix / noninteractive_prefix (command wrappers). See Session backends.[layouts.<name>] - a named layout: an activate probe 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, and for the editor open_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, default true) controls whether hop injects a clipboard_control override permitting OSC 52 clipboard reads without a per-paste prompt.workspace_layout = "<mode>" - sway workspace layout applied at first session entry. One of splith, 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.
.hop.tomlAs project's .hop.toml can declare shell commands (activate, prepare, teardown, translate helpers, window command, open_keys) that hop runs on your behalf. hop only runs a project's .hop.toml once you've trusted it with hop trust. Changes to .hop.toml on a live session have no effect unless explicitly hop trusted again.
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.
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).
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.
[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 without activate can 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 at hop kill after closing windows.port_translate (optional, string or list) - command(s) run lazily by the kitten/hints kitten when it dispatches a localhost / 127.0.0.1 / 0.0.0.0 URL. 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 localhost / 127.0.0.1 / 0.0.0.0 in the URL.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 as interactive_prefix. The implicit host backend 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.
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".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 |
|---|---|---|
| shell | login shell | active |
| editor | nvim | active |
| browser | xdg-detected default browser | inactive |
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.
A window command is typed into the role's shell as if you ran it there - so it lands in the shell's history (up-arrow re-runs it after a Ctrl-C) and any shell syntax works as written. Make a long-running role idempotent - survive a window close and reopen - by freeing its resource before it starts:
[layouts.rails.windows.server]
command = "fuser -k 3000/tcp 2>/dev/null; bin/dev"
The command runs in the same namespace as the session - inside the container for a devcontainer backend, on the remote over ssh - so use whatever the image has for the cleanup: pkill -f bin/dev, lsof -ti:3000 | xargs -r kill, etc.
hop --backend <name>
Forces a backend at session creation regardless of auto-detect. Use hop --backend host to keep the host backend in a project that would otherwise auto-activate something else. The choice is persisted for the session's lifetime.
Most roles are interchangeable - a kitty window running the session shell with the role's command typed into it. The editor and the browser are not: they are dispatch targets that hop open and the Kitty kitten route files and URLs to, and each carries extra fields and behavior of its own.
A session has exactly one editor window, shared by everything that opens a file into it. It's still an ordinary role terminal underneath - a shell with nvim typed in - so hop term --role editor creates it on first use and focuses it afterwards.
[windows.editor] activate = "false" opts out for a config.[windows.editor] command = "helix" swaps it - along with the keystroke templates that drive file-open dispatch, which are written for vim by default.hop open <file>[:<line>], a Rails Controller#action ref, and any file token picked by the Kitty kitten. Binary files are the exception: they open on the host, see Binary files open on the host.Pasting a clipboard image. Ctrl-V (and Ctrl-Shift-V) in any hop session window writes a clipboard image into the focused window's filesystem - inside the container or on the remote host when that's where the window runs - and pastes its path. Claude Code and Codex attach the file it points at; a plain shell or editor just receives the path. Anything other than an image falls through to kitty's native paste. If the paste fails or times out (e.g. a dropped ssh link), a one-line notice appears in the window.
The host needs wl-clipboard (wl-paste) installed for image paste. Change or disable the keys with [keys].paste. No kitty.conf changes are needed.
With nvim on a remote host or inside a container, point its clipboard provider at OSC 52 whenever no display is present:
if empty($WAYLAND_DISPLAY) && empty($DISPLAY)
let g:clipboard = 'osc52'
endif
Copy works with that alone. Paste ("+p) also works out of the box: hop allows OSC 52 clipboard reads in the session kitty ([clipboard].allow_read, default true), so nvim isn't prompted on every paste. The trade-off is that any program in the session's kitty can then read the system clipboard. Set allow_read = false to opt out and take the per-paste prompt back.
hop open <file>[:<line>] and the kitten/hints dispatch path drive the editor by writing raw bytes into its kitty window. Two [windows.editor] fields let you swap the byte sequence for any TUI editor:
open_keys - template used when the target has no line number.open_keys_with_line - template used when the target has a line number.Both are Python str.format templates. {path} substitutes the target path (with any literal ' doubled, see below); {line} substitutes the decimal line number.
Defaults reproduce vim/nvim's :drop fnameescape(...) exactly:
DEFAULT_OPEN_KEYS = "\x1b:exec 'drop '.fnameescape('{path}')\r"
DEFAULT_OPEN_KEYS_WITH_LINE = DEFAULT_OPEN_KEYS + ":{line}\r"
Pointing hop at helix:
[windows.editor]
command = "helix"
open_keys = "\u001b:open {path}\r"
open_keys_with_line = "\u001b:open {path}:{line}\r"
TOML basic strings disallow literal control bytes, so Escape has to be written as \u001b - TOML only defines \b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX. Reading the helix template: \u001b drops the editor out of insert mode, : enters command mode, open {path} is the open-file command, \r submits.
Literal single quotes in {path} are doubled before formatting, so a template that embeds {path} inside a single-quoted string (as the nvim default does) handles paths containing '. Templates that don't wrap {path} in '...' are unaffected.
The browser is the one window that isn't a kitty terminal. hop doesn't own the process - it launches (or adopts) a window of your existing default browser and tracks it with a Sway mark, so tabs, profile, and extensions are the ones you already have.
[windows.browser] activate = "true" turns it on for a config.[windows.browser] command = "..." overrides the detection.hop open <url> runs a URL through the backend's port_translate / host_translate first, so http://localhost:3000 inside the container becomes the host-reachable address.hop browser [<url>] focuses the session's browser window, creating it if there is none, and moves it back onto s:<session> if it drifted. With a URL, the URL goes to that window.A browser restart (relaunching after an update, or a reboot) loses the hop affiliation of every window; the browser's own session restore brings your tabs back as ordinary windows.
hop picks them back up. When a session has no marked browser window, hop browser adopts an unclaimed browser window already on s:<session> instead of launching a second one next to your restored tabs. After a restart, move the restored window onto the session's workspace and run hop browser - it marks it, focuses it, and dispatches URLs to it from then on. A window already marked for another session is never taken, so a browser window on another workspace stays yours.
Moving the session browser off s:<session> with raw Sway commands clears its mark; moving it back re-adopts it on the next hop browser.
The commands below are the integration surface for external tools. hop runs on the host; using the CLI from inside a container backend requires a shim (see docs/devcontainer.md).
hop run and hop waithop run "ls"
hop run --role test "python3 -m pytest -q"
hop run --role server "bin/dev"
hop run --role server --focus "bin/dev"
The command must be a single CLI argument. The default role is shell. hop run dispatches the command, prints an opaque run id, and returns immediately - it does not wait for completion.
By default hop run keeps the current focus. Pass --focus to focus the role terminal and switch Sway to the session's workspace.
id=$(hop run --role test "python3 -m pytest -q")
hop wait "$id"
hop wait blocks until the dispatched command returns to its shell prompt, then writes its output to stdout and exits with the command's own exit status. It exits 124 if it gives up first (10 minutes by default). This two-step protocol is what vigun uses to send a test run from the editor to a dedicated terminal in the session and collect its result once the run finishes.
hop list - print active Sway workspaces whose names start with s:.hop switch <name> - focus the Sway workspace s:<name>.hop move <name> - move the currently focused Sway window onto s:<name> and switch to that workspace.hop open <target> - route the target to the right place: a URL goes to the session browser (with the backend's localhost translation applied), a binary file (image, PDF, archive, ...) opens on the host with xdg-open, a Rails Controller#action ref or path[:line] goes to the session editor. See Binary files open on the host. The kitten under Open visible-output targets from Kitty uses the same parser.hop term --role <name> - focus or create the window for the given role, editor included.hop browser [<url>] - focus or create the session's browser window, and send it a URL if given.hop kill - close every Sway/Kitty window owned by the session, remove its workspace, and run the backend's teardown. Run from the session root.hop trust - trust the current directory's .hop.toml and, if the session is already live, refresh the trusted snapshot. hop trust --list shows every trusted config and flags drift; hop trust --revoke [path] drops one. See Trusting .hop.toml.Two log files help when something goes wrong:
debug_log (opt-in) - set debug_log = true in the config to append a diagnostic log of backend command runs (prepare / teardown / translate / auto-detect probes) and kitty bootstrap stdio to $XDG_RUNTIME_DIR/hop/debug.log. Set to a string to use a custom path. First place to look when hop fails silently - especially when launched from Vicinae, where stderr is not visible.prepare / teardown popup streams its terminal output to $XDG_RUNTIME_DIR/hop/popup-<session>-<kind>.log (one file per session and kind, overwritten each run). cat $XDG_RUNTIME_DIR/hop/popup-myproj-prepare.log shows exactly what the last prepare run printed - the place to look when a prepare script misbehaves and the popup closed before you could read it.In-depth guides live under docs/:
docs/devcontainer.md - step-by-step devcontainer backend setup and troubleshooting.docs/hop-ssh.md - running a session on a remote machine with hop ssh.docs/vigun.md - the hop run / hop wait contract behind the vigun editor integration.docs/ssh.md and docs/ssh-devcontainer.md - the hand-wired ssh recipes that hop ssh supersedes.uv sync
make # to run tests and lints
237 commits
Python
99.8%