hop Logo

hop

**Hop from server to server without ever leaving your terminal.** One keypress and you're in a shell. One more and you're browsing its files. One more and you're editing one *on the box*, in a tab, beside all the others. Then hop to the next host and everything you left behind is still exactly where you left it. *No new windows. No re-authenticating. No hunting for that one shell you had open.* [![CI](https://github.com/p-arndt/hop/actions/workflows/ci.yml/badge.svg)](https://github.com/p-arndt/hop/actions/workflows/ci.yml) [![Release](https://github.com/p-arndt/hop/actions/workflows/release.yml/badge.svg)](https://github.com/p-arndt/hop/actions/workflows/release.yml) [![Go](https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white)](go.mod) [![Platforms](https://img.shields.io/badge/platforms-windows%20%7C%20macOS%20%7C%20linux-informational)](#-install) [![Built with Bubble Tea](https://img.shields.io/badge/built%20with-Bubble%20Tea-ff69b4)](https://github.com/charmbracelet/bubbletea) [Docs](https://p-arndt.github.io/hop/) · [Install](#-install) · [Quick start](#-quick-start) · [Keys](#-keys) · [Development](#-development) · [Roadmap](#-roadmap)
--- > [!NOTE] > **hop is in early development.** Things may break, change, or behave in ways they > shouldn't. If you hit a bug, have an idea, or something just feels off, > [open an issue](https://github.com/p-arndt/hop/issues)! Feedback at this stage is > genuinely the most useful thing you can contribute. 🙌 ## 🎬 What it looks like

hop: filter the fleet, open a shell, open a second one, browse the files, edit one on the server

Keys are shown bottom-right as they're pressed. `●` connected · `◐` connecting · `○` idle · `×2` two shells open · `▤` SFTP browser open · `⇄2` two tunnels running hop is a terminal UI over your SSH fleet. It holds one connection per host and opens everything else as extra channels on that same connection: more shells, an SFTP browser, an editor running on the remote box, and any tunnels you defined. Nothing is a new window, nothing re-authenticates, and leaving a pane never tears down what is inside it. The header always tells you **where your keystrokes are going**. That is the single most disorienting thing about a TUI that embeds other people's programs, so it gets permanent screen space.
The host list with the details card for the host under the cursor
The host list. Status dot, group, and what enter would do to the host under the cursor.
A live remote shell in a hop pane
A shell. A real terminal in the pane; the footer shows the ways back out.
The SFTP file browser
The SFTP browser. f, over the connection that's already open.
A file open in a remote editor tab inside hop
A remote editor tab. enter on a file runs the editor on the server, so :w writes the real file.
More stills: two shells on one connection, settings, the keys card
Two shells on one host, shown as a tab strip
Two shells, one connection. S (or ctrl+o 0) opens another channel, no second handshake.
The settings popover
Settings. ,. The accent is a swatch strip that recolours hop as you walk it.
The keys card listing every binding
Every key hop binds. ?. It lists the keyboard you actually have, with vim motions included only if you turned them on.
Everything in the recording is invented: it runs against a throwaway SSH server (`tools/demoserver`) with a HOME of its own, so `just demo` re-records it anywhere without exposing anything. ## ✨ Features | | | | --- | --- | | 🖥️ **Embedded SSH shells** | Real terminals in a pane: a pure-Go SSH client (`x/crypto/ssh`) feeding a real VT emulator (`x/vt`). Agent or private-key auth, resize, cursor, the lot. | | 🔑 **2FA and passwords** | A card appears the moment the host asks. Nothing is stored, and one prompt per host covers everything riding that connection. | | 🗂️ **Multiple shells per host** | `S` opens a second channel on the connection you already have — no new handshake, no second auth. | | 📁 **SFTP file browser** | `f` browses the remote filesystem over that same connection. Download with `d`, open locally with `o`. | | ⇄ **Local & remote tunnels** | Define forwards per host with `T`, start and stop them with `t`. Imported from your SSH config, restored on reconnect. | | 🛰️ **ProxyCommand & ProxyJump** | Bastions and brokers (`aws ssm`, `cloudflared`, `gcloud`) work exactly as your SSH config describes them. | | ✎ **Remote editor tabs** | `enter` on a file runs `$EDITOR` *on the server* in a tab. No download — `:w` writes the real file. | | 📂 **VS Code where you are** | `ctrl+o` `c` opens VS Code Remote in the directory the shell is standing in, tracked over OSC 7. | | 🏠 **A directory to land in** | Give a host a default directory and every session on it starts there. | | ⇅ **Scrollback** | `shift+↑` pauses a shell into its history, with vim-ish paging. | | 🔁 **Reconnect after a drop** | Drops are noticed, the last screen is kept, and `r` puts the shells, browser and tunnels back. | | 🔐 **Honest host keys** | An unknown key aborts the dial and shows a fingerprint card. A mismatch is always a hard error. | | 📥 **SSH config import** | `i` syncs hosts from `~/.ssh/config` — a re-import refreshes, hand-added hosts are left alone. | | 🔎 **Fuzzy find** | `/` filters as you type, with the matched characters picked out so a surprising hit explains itself. | | 🖱️ **Mouse** | Wheel, click and drag-to-copy everywhere; remote programs that want the pointer get it verbatim. | | 📋 **Copy and paste** | Bracketed paste (even on Windows), drag to copy, and remote yanks (OSC 52) land on your clipboard. | | ⚙️ **Live settings** | `,` — editor, download dir, accent colour, vim keys, mouse, remote clipboard. Applied on the spot. | | 🎯 **Frecency ordering** | The hosts you actually use float to the top of the list. | | 🪟 **Cross-platform** | Static, dependency-free binaries for Windows, macOS and Linux (amd64 + arm64). No cgo, no libc, no runtime. | | ⌨️ **Opt-in vim keys** | Off by default. Flip one switch and the motions appear everywhere at once. |
The longer version of a few of these **2FA.** The dial waits inside the handshake rather than restarting — a one-time code is only good once. One prompt per host, since shells, SFTP, editors and tunnels ride the same connection. **ProxyCommand.** The command runs *without a shell*: a line needing one is refused with a clear error, so an imported config cannot smuggle in `sh -c`. `%h` `%p` `%r` `%n` are expanded. A `ProxyJump` may name another hop host by alias and borrows its user, port and key. **OSC 7.** hop installs the prompt hook into bash/zsh itself — one line typed at the first prompt, then wiped from the pane, so the session looks untouched. Nothing is typed into a shell that already emits OSC 7, or while a full-screen program owns the screen. Where no directory can be learned, the key still opens the host in its default directory and says so. **Reconnect.** Drops are found with keepalive probes rather than silence, so a suspended laptop or a dead VPN does not leave a pane quietly frozen.
## 📦 Install Grab the archive for your platform from the [latest release](https://github.com/p-arndt/hop/releases/latest) and put `hop` somewhere on your `PATH`. ```bash # macOS / Linux tar -xzf hop_*_darwin_arm64.tar.gz sudo mv hop /usr/local/bin/ ``` ```powershell # Windows Expand-Archive hop_*_windows_amd64.zip -DestinationPath . # then move hop.exe onto your PATH ``` Every release ships a `hop__checksums.txt`, so you can verify with `sha256sum -c`. ### From source Needs [Go 1.26+](https://go.dev/dl/) and optionally [`just`](https://github.com/casey/just) ≥ 1.39. ```bash git clone https://github.com/p-arndt/hop.git && cd hop just build # -> ./hop (or: go build -o hop .) just build-release # stripped + version-stamped ``` ### Staying current ```bash hop check-update # is there a newer release? hop self-update # download it, verify its checksum, swap this binary ``` `self-update` fetches the archive for *your* platform from the latest GitHub release, checks its SHA-256 against that release's `checksums.txt`, and replaces the running binary atomically. On Windows the old `hop.exe` is renamed aside and swept up the next time hop starts. Source builds (`version = dev`) are refused: there is nothing to compare them against. > [!NOTE] > hop also checks once a day in the background and mentions a newer version in the footer and > on the CLI. `HOP_NO_UPDATE_CHECK=1` turns that off; the two commands above still work. ## 🚀 Quick start ```bash hop # launch the TUI ``` On a first run with no hosts, hop offers to import `~/.ssh/config` for you: one `enter` and your list is full. Then, in the TUI: `↑`/`↓` to move, `enter` to connect, `ctrl+o` `o` to come back out. That is the whole model. ### The CLI For when you are already in a shell. | Command | What it does | | --- | --- | | `hop` | launch the TUI | | `hop import [path]` | sync hosts from `~/.ssh/config`, or from another file | | `hop add web1 deploy@10.0.0.4:2222` | add a host by alias and target | | `hop list` | print `alias user@host:port` | | `hop check-update` | is a newer release out? | | `hop self-update` | upgrade this binary in place | | `hop version` | print the version | ### Where things live | What | Path | | --- | --- | | Host database | `/hop/hop.db` (SQLite) | | Settings | `/hop/config.json` (plain JSON, hand-editable) | | Update check cache | `/hop/update-check.json` (last check + latest version seen) | | Known hosts | your usual `~/.ssh/known_hosts` | | Platform | `` | | --- | --- | | Windows | `%AppData%\hop\` | | macOS | `~/Library/Application Support/hop/` | | Linux | `~/.config/hop/` | A missing or malformed config file starts hop on defaults rather than refusing to start. ## ⌨️ Keys The footer always shows the keys for the mode you are in, and every mode returns to the host list — from a pane with `ctrl+o` `o`, from the browser with `ctrl+o`, and from either with a double `esc` inside 400 ms. | Mode | You're here when | Who owns your keystrokes | Read next | | --- | --- | --- | --- | | **Navigation** | the host list is focused (the default) | hop | [Host list](KEYBINDINGS.md#navigation--the-host-list) · [SSH config import](KEYBINDINGS.md#import--the-ssh-config-card) · [Host keys](KEYBINDINGS.md#host-keys-and-authentication) | | **Terminal** | you connected with `enter` or `s` | the **remote shell** | [Shells](KEYBINDINGS.md#terminal--a-live-shell-on-a-remote-host) · [Scrollback](KEYBINDINGS.md#scrolling-back-through-history) | | **Browsing** | you opened the SFTP browser with `f` | hop | [File browser](KEYBINDINGS.md#browsing--the-sftp-file-browser) · [Editor tabs](KEYBINDINGS.md#editing--editor-tabs) | Everything else works in **all** of them: the [sidebar toggle](KEYBINDINGS.md#the-sidebar--ctrlb), the [settings popover](KEYBINDINGS.md#settings--the-popover), the [tunnels](KEYBINDINGS.md#tunnels--port-forwarding), the [mouse](KEYBINDINGS.md#the-mouse) and the optional [vim keys](KEYBINDINGS.md#vim-keys). **Two rules explain most of the keyboard:** - **Inside a pane, `ctrl+o` is [hop's leader](KEYBINDINGS.md#the-leader--ctrlo).** It does nothing on its own and it is on no clock — it opens a menu in the footer and waits. `ctrl+o` `o` goes **out**. - **Outside a pane** — in the browser, in a card — `ctrl+o` simply goes back. There is no remote program competing for keys there, so there is nothing to lead. - **Inside a pane, everything else is the remote's.** hop reserves as few keys as it can, because every one it takes is one the shell or editor no longer gets.
The host list | Key | Action | | --- | --- | | `↓` `↑` | move | | `pgdn` `pgup` | a full page down / up | | `enter` `→` | connect (opens a terminal pane), or focus the shell already open | | `esc` `←` | back — leave the details view | | `s` | focus the existing session for this host | | `S` | open **another** shell on this host, alongside the ones already open | | `1` … `9` | go straight to that shell of the host under the cursor | | `f` | open the SFTP browser | | `t` | start all defined tunnels, or stop them when any are running | | `T` | manage this host's tunnel definitions | | `o` | open the host in VS Code Remote, in the directory its shell is standing in | | `d` | disconnect the session | | `r` | reconnect a session whose connection dropped, reopening what it held | | `a` `e` `x` | add / edit / delete a host (delete asks first) | | `p` | pin the host to the **PINNED** section at the top, or unpin it | | `shift+k` `shift+j` | move a pinned host up / down inside that section | | `i` | import hosts from an OpenSSH config (`~/.ssh/config` by default) | | `/` | filter hosts (`enter` applies, `esc` clears) | | `,` `?` | settings / the keys card | | `ctrl+b` | hide / show the sidebar | | `ctrl+g` | hand the mouse to your terminal (and take it back) | | `q` `ctrl+c` | quit | With [vim keys](KEYBINDINGS.md#vim-keys) on, `j`/`k` move, `l` connects as `enter` does, and `h` goes back as `esc` does.
Inside a shell | Key | Action | | --- | --- | | `ctrl+o` `o` | **out** — back to hop | | `esc` `esc` | back to hop (two presses within 400 ms) | | `shift+→` `shift+←` | next / previous shell on this host (wraps) | | `ctrl+o` `1` … `9` | go straight to that shell, without leaving the pane | | `ctrl+o` `0` | open **another** shell on this host, without leaving the pane | | `ctrl+o` `c` | open **this directory** in VS Code Remote | | `shift+↑` `shift+pgup` | scroll back into the pane's history | | `ctrl+b` | hide / show the sidebar — the pane takes the whole window | | `ctrl+g` | hand the mouse to your terminal (and take it back) | | `alt+0`, `alt+←`/`alt+→`, `alt+1`…`alt+9` | aliases for the above, where your terminal sends them | | *everything else* | sent to the remote shell | ### Several shells on one host `S` in the host list, or `ctrl+o` `0` from inside the pane, opens another shell on a host you are already connected to. It is a second **channel** on the connection hop already holds — no new handshake, no second authentication — and it appears as a tab strip above the pane, which shows up only once there is a second shell to switch to. The new shell arrives focused. Type `exit` to close a shell: its tab goes away, the rest keep running. When the last one exits, the connection is done and the host goes back to idle in the list — unless its SFTP browser, an editor tab or a tunnel is still open on it, which keeps the connection alive. `d` still tears down the whole host at once.
The leader — ctrl+o `ctrl+o` inside a pane has **no effect of its own**, and no timeout. It opens the leader, the footer becomes the menu, and hop waits as long as you take: | after `ctrl+o` | | | --- | --- | | `o` | out — back to hop | | `1` … `9` | that tab, selected **in place** | | `0` | another shell on this host | | `c` | this directory in VS Code Remote | | anything else | closes the leader and does nothing | A key that names no chord is **swallowed**, not passed to the remote: while the leader is open hop has the keyboard, and a program that received the tail of an abandoned chord would act on a key you were not typing at it. The leader also outranks `ctrl+b` and `ctrl+g`, which are otherwise held in every mode.
The file browser | Key | Action | | --- | --- | | `↓` `↑` | move | | `pgdn` `pgup` | a full page down / up | | `enter` `→` | enter a directory, or open a file in an editor tab | | `o` | open the file in the local OS default app (GUI) | | `d` | download the file to `~/Downloads` | | `←` `backspace` | up one directory | | `r` | refresh the listing | | `,` | settings | | `ctrl+b` | hide / show the sidebar | | `ctrl+o` | back to hop | | `esc` `esc` | back to hop (two presses within 400 ms) | With [vim keys](KEYBINDINGS.md#vim-keys) on the browser keeps the *whole* motion set (the host list only the step keys): `j`/`k`, `gg`, `G`, `H`/`M`/`L`, `ctrl+d`/`ctrl+u`, `ctrl+f`, plus `l` to descend and `h` to back out.
Editor tabs `enter` on a file opens it in an editor **inside hop**, in the same right-hand pane the browser lives in, with a tab strip above it listing every open file. | Key | Action | | --- | --- | | `shift+→` `shift+←` | next / previous tab (wraps) | | `ctrl+o` `1` … `9` | go straight to that tab, without leaving | | `ctrl+o` `o` | back to the file browser | | `:q` (i.e. quit the editor) | close the tab | | `esc` `esc` | back to the file browser (two presses within 400 ms) | | `alt+←`/`alt+→`, `alt+h`/`alt+l`, `alt+1`…`alt+9` | aliases, where your terminal sends them | | *everything else* | sent to the remote editor | ### How it works The editor runs **on the remote host**, not locally: hop opens a second SSH channel on the connection it already has and runs `${EDITOR:-vi} ` on a pty, then renders that pty in a pane exactly as it renders a remote shell. There is no download and no copy — you are editing the real file, and `:w` writes straight back to the server. If the remote `$EDITOR` is unset (it usually is over SSH, since the rc-file that sets it is never sourced for a non-interactive command), hop probes the remote `PATH` for `nvim`, `vim`, `vi`, then `nano`, falling back to `vi` — POSIX requires it to exist. Tabs are independent editor processes, so leaving with `ctrl+o` `o` keeps them all running: come back and every file is where you left it, cursor included. ### Editing locally instead `o` is the escape hatch for files a terminal editor is no good for — a PDF, an image. It downloads the file to a scratch directory under the system temp dir and hands that copy to the desktop (`start` on Windows, `open` on macOS, `xdg-open` elsewhere), returning immediately so hop stays usable. Unlike `enter`, this edits a *local copy*: nothing is written back to the remote host. `d` downloads without opening anything.
Tunnels Forwards are defined **per host** and ride the connection hop already holds, so a tunnel costs no extra handshake and no second authentication. | Key | Where | Action | | --- | --- | --- | | `t` | the host list | start every defined tunnel, or stop them all when any are running | | `T` | the host list | open this host's tunnel manager | | `↑` `↓` | the manager | select a definition | | `enter` `space` | the manager | start or stop the selected one | | `a` `e` `x` | the manager | add / edit / delete a definition | | `t` | the manager | close the card and start or stop the whole set | | `esc` | the manager | close | A definition is a direction (**local** or **remote**), a bind address and port, and the host and port to reach on the far side. `LocalForward` and `RemoteForward` lines are picked up by the [SSH config import](KEYBINDINGS.md#import--the-ssh-config-card), so the forwards you already have keep working. Editing a running definition stops the old one on save, and a [reconnect](KEYBINDINGS.md#when-a-connection-drops) puts the set that was running back up. The status dot in the host list shows `⇄2` when two tunnels are up on that host.
When a connection drops Drops are found with keepalive probes rather than silence, so a suspended laptop or a dead VPN does not leave a pane quietly frozen. | Key | Action | | --- | --- | | `r` `enter` | reconnect: dial again and reopen what was open | | `d` `x` | drop the session — the pane goes, the host is idle again | | `ctrl+o` `esc` `q` | back to the host list, leaving the pane on screen | The pane keeps the last screen the host drew, under a banner saying what happened, so the command that was running is still there to read. Nothing is forwarded to the far end, because there is no far end. Shell tabs, the browser's directory and the running tunnels come back on reconnect. Editor tabs do not — an editor holds a buffer, and reopening the file on a fresh pty would look like nothing was lost — and the status says how many were left behind.
📖 **[KEYBINDINGS.md](KEYBINDINGS.md)** is the full reference, including the vim motions, the settings popover, the mouse, and *why* each reserved chord is reserved. The same thing, prettier and searchable, is at **[p-arndt.github.io/hop](https://p-arndt.github.io/hop/)** — both are generated from `docs/*.md` by `just docs`. ## 🛠️ Development ```bash just # list recipes just run list # go run . list just build # dev binary just test # go test ./... just test-e2e # + the Docker 2FA end-to-end tests (needs Docker) just vet just fmt # gofmt -w . just ci # fmt-check + vet + test (what CI runs) just docs # regenerate index.html, README.md and KEYBINDINGS.md from docs/ just demo # re-record assets/demo.gif + the stills (needs vhs) ```
How the docs, the demo, the tests and the release are put together **The docs.** `docs/*.md` is the only source for the website, the README and the keybinding reference; `tools/docsgen` renders all three, and `just ci` fails if a generated file is out of date. Sections carry frontmatter saying where they belong, and a handful of fenced directives (`:::cards`, `:::why`, `:::figure`) cover the layouts markdown has no syntax for — each with a plain-markdown lowering, so the same section reads well on GitHub. **The justfile** is deliberately universal: recipe bodies are plain commands that run under both `sh` and PowerShell, and the two that need real shell logic (`fmt-check`, `clean`) are split with `[unix]` / `[windows]` attributes. **The demo.** `just demo` records the GIF and the stills on this page. `scripts/demo.mjs` builds hop, points `HOME` at a throwaway directory with a seeded host database, and starts `tools/demoserver` — a loopback-only SSH server that invents everything on screen: a fake shell with a table of canned command output, an in-memory filesystem over SFTP, and a fake vi. The keypress overlay in the corner is hop's own, compiled in only under `-tags hopdemo` (`internal/tui/keycast.go`), so a released binary does not carry it. **Testing.** Headless tests drive the real Bubble Tea model with real keystrokes against in-process Go SSH/SFTP servers and temp-file stores — see `internal/tui/hostmgmt_test.go`, `TestEmbeddedRoundTrip`, `TestSFTPRoundTrip`. CI runs vet + test + build on a Windows / Linux / macOS matrix, because the agent transport and the local-open handler are per-platform: a single-OS run cannot tell whether the others still compile. **The 2FA end-to-end tests.** An in-process server that answers whatever you tell it to proves nothing about real two-factor auth, so `internal/dockerenv` brings up Ubuntu with the real `openssh-server` and `libpam-google-authenticator`, listening four ways: code alone, hardened `publickey,keyboard-interactive`, password-then-code, and both offered as alternatives. The tests compute TOTP codes the way a phone does and log in, with wrong and expired codes as negative controls. Opt in with `just test-e2e`; without `HOP_DOCKER_E2E=1` they skip. **Releasing.** ```bash just release # patch bump: stamps VERSION, commits, tags, pushes just release minor # or major, or an explicit 1.0.0 ``` The tag push triggers the release workflow: it gates on the three-OS test matrix, then cross-compiles all six targets (windows/linux/darwin × amd64/arm64) from one Linux runner, with checksums and a git-cliff changelog. Windows gets a `.zip`, everything else a `.tar.gz` so the exec bit survives.
## 🗺️ Roadmap **Shipped:** embedded shells · multi-shell tabs · SFTP browser · remote editor tabs · scrollback · tunnels · ProxyCommand/ProxyJump · 2FA · reconnect · mouse · copy and paste · host management · host-key confirmation · SSH config import · live settings · cross-platform releases. Next up: | | | | --- | --- | | 💓 **Health panel** | Per-host reachability, latency, uptime and disk, shown like VS Code's connection status. | | ⬆️ **Uploads & file ops** | `u`, `x`, `R`, `m` in the browser, with async transfer progress. | | 🏷️ **Groups & tags** | Section the list by group, filter by tag, pin favourites. | | 📐 **Narrow terminals** | Narrow-terminal layouts and cursor-style fidelity. | The living version, with far more detail on each item, is [TODO.md](TODO.md). ## 🙏 Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) · [Lip Gloss](https://github.com/charmbracelet/lipgloss) · [x/vt](https://github.com/charmbracelet/x) · [x/crypto/ssh](https://pkg.go.dev/golang.org/x/crypto/ssh) · [pkg/sftp](https://github.com/pkg/sftp) · [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) · [sahilm/fuzzy](https://github.com/sahilm/fuzzy) · [skeema/knownhosts](https://github.com/skeema/knownhosts)
Made for people with too many servers and not enough terminals.