返回资讯中心
外部精选
软件工程
#开发者实时流

Show HN: I replaced tmux with sway workspaces

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

Hacker News 最新artemave2026/10/25 · 11 天前30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

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

intro_full.mp4

Each project gets its own hop session - a dedicated Sway workspace identified by its working directory, holding the editor, terminals, and browser. hop also takes care of session lifecycle (prepare, teardown).

A hop session is conceptually similar to a tmux session, but with a fundamental difference: window management is left to the actual system window manager.

This alone has a few practical consequences:

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.

Linux

Python

Sway window manager

Kitty terminal emulator

A TUI editor (Neovim by default)

Optionally:

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:

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

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:

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.

The same targets also become hyperlinks under the mouse: hover one and click to dispatch it, no picker needed. Targets soft-wrapped across rows are linked as a whole. A hop link replaces any link the program printed itself (OSC 8) that overlaps it. Clicking any web URL in a session window - hop's link or not - opens it in the session browser, with the backend's localhost translation applied. In a program that captures the mouse (e.g. Claude Code's fullscreen UI), a plain click belongs to the program - shift+click a link instead.

This needs a kitty build whose watchers support on_mouse_move and screen.set_hyperlink_for_range() - upstream kitty has neither. hop registers the watcher in every session kitty regardless; kitty builds without these features never call it.

Choosing a .png (or similar) in the open-selection kitten opens the file with your host's xdg-open instead of nvim - so PNGs land in your image viewer, PDFs in your reader, archives in your file manager, etc. This seamlessly works over ssh as well. Directories are the exception: they open in nvim.

A hop config has several named sections plus a few scalar settings, all optional:

Configs live in ~/.config/hop/config.toml or a project's .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.

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.

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

command (string) - the role command, without any backend wrap. It's typed into the role's session shell, so it lands in shell history and the window stays a usable shell after the command exits. An empty string (e.g. [layouts.rails.windows.test] command = "") is just that bare shell.

activate (string, optional) - shell probe; the window auto-launches when it exits 0. Defaults to "true".

position (integer, optional) - sticks the window to a slot in the row of windows on the session's workspace. Positive counts from the left (1 = leftmost), negative counts from the right (-1 = rightmost). Unpositioned windows fill the middle, in creation order:

[windows.shell] position = 1

[windows.server] command = "bin/dev" position = -1

With this config, shell always opens leftmost and server always opens rightmost; any other window (declared or ad hoc, like the shell-2 spawned by re-running hop from inside the session) opens between them. Only the newly-created window is repositioned - existing windows keep their relative order. The session browser participates in the same row as the kitty role terminals, even though it isn't a kitty window.

Sticky positioning is on by default: shell, editor, and browser ship with built-in positions 1, 2, and 3 respectively, so a fresh session always lays out shell, editor, and browser leftmost-to-rightmost in that order, ahead of any other window (any additional window without an explicit position lands after all three, since unpositioned windows fill the middle bucket, which always sorts after every positive position). Set the top-level sticky_positions = false to turn the whole feature off - every position, built-in or explicitly configured, is then ignored and Sway's own tiling placement is left alone:

sticky_positions = false

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:

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

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →