artemave/hop

Project session manager built on Sway and Kitty.

9

stars

237

commits

Python

primary language

Sep 11, 2026

updated

swaywm

README

hop is a project session manager built on Sway and Kitty.

Demo

https://github.com/user-attachments/assets/e20d8280-6a7e-4a13-ab6d-9540b74498ac

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:

  • 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 hop CLI 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

  • Linux
  • Python
  • Sway window manager
  • Kitty terminal emulator
  • A TUI editor (Neovim by default)

Optionally:

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. Plus Hop kill for the focused session and Hop 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.rb
  • app/models/user.rb:42
  • b/app/models/user.rb
  • https://example.com
  • Processing 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.

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.

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 (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.

Trusting .hop.toml

As 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.

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 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.

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".

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:

rolecommand defaultactivate default
shelllogin shellactive
editornvimactive
browserxdg-detected default browserinactive

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.

Per-invocation override

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.

Special windows

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.

Editor

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.

  • Active by default. [windows.editor] activate = "false" opts out for a config.
  • Any TUI editor works. [windows.editor] command = "helix" swaps it - along with the keystroke templates that drive file-open dispatch, which are written for vim by default.
  • It's the target for every file-shaped dispatch - 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.
  • It runs on the backend, inside the container or on the remote 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.

System clipboard for the editor on non-host backends

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.

Keystroke templates

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.

Browser

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.

  • Inactive by default - the only built-in role that is. [windows.browser] activate = "true" turns it on for a config.
  • The default command is xdg-detected from the default browser's desktop entry. [windows.browser] command = "..." overrides the detection.
  • It always runs on the host, even when the session's backend is a container or a remote machine. 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.

When the browser restarts

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.

Automation

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 wait

hop 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.

Other commands

  • 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.

Troubleshooting

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.
  • Lifecycle popup logs (always on) - every 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.

Further reading

In-depth guides live under docs/:

Development

uv sync
make # to run tests and lints

Contributors

artemave

237 commits

artemave/hop

Project session manager built on Sway and Kitty.

9

stars

237

commits

Python

primary language

Sep 11, 2026

updated

swaywm

README

hop is a project session manager built on Sway and Kitty.

Demo

https://github.com/user-attachments/assets/e20d8280-6a7e-4a13-ab6d-9540b74498ac

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:

  • 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 hop CLI 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

  • Linux
  • Python
  • Sway window manager
  • Kitty terminal emulator
  • A TUI editor (Neovim by default)

Optionally:

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. Plus Hop kill for the focused session and Hop 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.rb
  • app/models/user.rb:42
  • b/app/models/user.rb
  • https://example.com
  • Processing 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.

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.

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 (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.

Trusting .hop.toml

As 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.

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 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.

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".

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:

rolecommand defaultactivate default
shelllogin shellactive
editornvimactive
browserxdg-detected default browserinactive

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.

Per-invocation override

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.

Special windows

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.

Editor

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.

  • Active by default. [windows.editor] activate = "false" opts out for a config.
  • Any TUI editor works. [windows.editor] command = "helix" swaps it - along with the keystroke templates that drive file-open dispatch, which are written for vim by default.
  • It's the target for every file-shaped dispatch - 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.
  • It runs on the backend, inside the container or on the remote 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.

System clipboard for the editor on non-host backends

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.

Keystroke templates

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.

Browser

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.

  • Inactive by default - the only built-in role that is. [windows.browser] activate = "true" turns it on for a config.
  • The default command is xdg-detected from the default browser's desktop entry. [windows.browser] command = "..." overrides the detection.
  • It always runs on the host, even when the session's backend is a container or a remote machine. 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.

When the browser restarts

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.

Automation

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 wait

hop 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.

Other commands

  • 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.

Troubleshooting

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.
  • Lifecycle popup logs (always on) - every 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.

Further reading

In-depth guides live under docs/:

Development

uv sync
make # to run tests and lints

Contributors

artemave

237 commits

Languages

Python

99.8%