Skip to content

Commit db398b2

Browse files
binarypieclaude
andauthored
Add unified keybindings proposal for #198 (#210)
Proposes collapsing the multi-leader/multi-mode mess across zellij, Ghostty, workmux, nvim, and the agent CLIs into a single scheme: - One multiplexer (zellij owns panes/tabs; strip Ghostty's leader) - Three-tier model: Super = OS, <leader> = mux, Space = editor - zellij clear-defaults + one leader-entered mode (no mode soup) - Seamless Alt+hjkl focus across zellij <-> nvim via vim-zellij-navigator - Leader-key trade-off (Ctrl+Space recommended over Ctrl+a) so the agent CLIs keep their keys RFC only; changes no live configs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDzdtoy59UEWDZvZhZBkhB --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ab498a commit db398b2

14 files changed

Lines changed: 485 additions & 125 deletions

File tree

KEYBINDINGS.md

Lines changed: 98 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
11
# Keybindings Reference
22

3-
Hypercube uses consistent vim-style keybindings across all tools. This document provides a complete reference.
3+
Hypercube uses consistent vim-style keybindings across all tools. This document
4+
provides a complete reference.
5+
6+
## The 3-tier model
7+
8+
Hypercube spans a host desktop and the `devc` container, and your keystrokes pass
9+
through several programs at once. To avoid the "keyboard dance," keys follow one
10+
rule: **one modifier per layer, never overlapping.** You only ever reach for one
11+
of three things, and they live in different worlds:
12+
13+
| Press | Talks to | Layer |
14+
|---|---|---|
15+
| **`Super` + …** | the OS | Hyprland windows & workspaces |
16+
| **`Ctrl+Space`** then a key | the multiplexer | zellij (inside `devc`): panes, tabs, sessions, agents |
17+
| **`Space` + …** | the editor | Neovim (LazyVim) |
18+
19+
`Ctrl+Space` is a single **leader**: tap it to enter one mode, then press one key.
20+
There is no pane-mode vs tab-mode vs resize-mode to juggle. See
21+
[KEYBINDINGS-PROPOSAL.md](KEYBINDINGS-PROPOSAL.md) for the full rationale
22+
([#198](https://github.com/binarypie-dev/hypercube/issues/198)).
23+
24+
> zellij owns multiplexing under the `Ctrl+Space` leader and Ghostty is a plain
25+
> renderer (no terminal leader). Within a tab, `Alt+hjkl` moves between zellij
26+
> panes; Neovim keeps its own `Ctrl+hjkl` for moving between its splits. The two
27+
> are intentionally separate — Neovim is the editor layer, zellij the mux layer.
428
529
## Hyprland Window Management
630

@@ -71,49 +95,94 @@ The **Super** key (Windows/Command key) is the main modifier.
7195

7296
## Ghostty Terminal
7397

74-
Ghostty uses **Ctrl+A** as the leader key (similar to tmux/screen).
98+
Ghostty is a plain, chrome-less renderer. **Multiplexing — tabs, splits,
99+
panes — is owned by zellij** (see the next section), so Ghostty defines no
100+
terminal leader of its own. Dropping the old `Ctrl+A` prefix also frees it as
101+
readline "beginning of line" when you run `devc claude` in a plain window.
75102

76-
### Tabs
103+
Ghostty keeps just two custom binds (plus its built-in copy/paste and
104+
scrollback defaults):
77105

78106
| Keybinding | Action |
79107
|------------|--------|
80-
| `Ctrl+A, C` | New tab |
81-
| `Ctrl+A, N` | Next tab |
82-
| `Ctrl+A, P` | Previous tab |
83-
| `Ctrl+A, W` | Tab overview |
108+
| `Ctrl+Shift+N` | New window (for local console work outside the multiplexer) |
109+
| `Ctrl+Shift+,` | Reload config |
110+
| `Ctrl+Shift+C` / `V` | Copy / paste (Ghostty default) |
111+
112+
---
113+
114+
## zellij (devcube multiplexer)
115+
116+
Inside `devc`, **zellij** is the terminal multiplexer — it manages your panes,
117+
tabs, and sessions, and each parallel agent (workmux worktree) is a zellij tab.
118+
119+
The leader is **`Ctrl+Space`**. Tap it to enter **HYPER mode**, then press one
120+
key. Every key runs its action and returns to Normal, so it reads like a leader
121+
sequence — `Ctrl+Space |` splits, `Ctrl+Space c` makes a tab. There is only one
122+
mode to learn. `Esc` / `Enter` leaves HYPER mode.
84123

85-
### Panes (Splits)
124+
### Panes
86125

87126
| Keybinding | Action |
88127
|------------|--------|
89-
| `Ctrl+A, Shift+\` | Vertical split |
90-
| `Ctrl+A, -` | Horizontal split |
91-
| `Ctrl+A, X` | Close pane |
92-
| `Ctrl+A, Z` | Zoom/unzoom pane |
93-
| `Alt+F` | Toggle fullscreen pane |
94-
95-
### Pane Navigation (Vim-style)
128+
| `Ctrl+Space` `\|` | Split right (vertical) |
129+
| `Ctrl+Space` `-` | Split down (horizontal) |
130+
| `Ctrl+Space` `n` | New pane |
131+
| `Ctrl+Space` `x` | Close pane |
132+
| `Ctrl+Space` `z` | Toggle zoom/fullscreen |
133+
| `Ctrl+Space` `w` | Toggle floating panes |
134+
| `Ctrl+Space` `e` | Embed / float pane |
135+
| `Ctrl+Space` `h/j/k/l` | Move focus |
136+
| `Ctrl+Space` `H/J/K/L` | Move the pane |
137+
138+
### Tabs (workmux worktrees)
96139

97140
| Keybinding | Action |
98141
|------------|--------|
99-
| `Ctrl+A, H` | Focus pane left |
100-
| `Ctrl+A, J` | Focus pane down |
101-
| `Ctrl+A, K` | Focus pane up |
102-
| `Ctrl+A, L` | Focus pane right |
142+
| `Ctrl+Space` `c` | New tab |
143+
| `Ctrl+Space` `[` / `]` | Previous / next tab |
144+
| `Ctrl+Space` `1``9` | Jump to tab _n_ |
145+
| `Ctrl+Space` `,` | Rename tab |
103146

104-
### Pane Resizing
147+
### Resize, session, lock
105148

106149
| Keybinding | Action |
107150
|------------|--------|
108-
| `Ctrl+A, R` | Enter resize mode |
109-
| Then `H/J/K/L` | Resize in direction |
151+
| `Ctrl+Space` `r` | Resize sub-mode (then `h/j/k/l` or `+`/`-`; `Esc` exits) |
152+
| `Ctrl+Space` `s` | Session manager |
153+
| `Ctrl+Space` `d` | Detach session (leave agents running) |
154+
| `Ctrl+Space` `q` | Quit — floating prompt: **Save** (resume on next `devc`), **Discard**, or **Cancel** |
155+
| `Ctrl+Space` `g` | Lock — pass every key to a focused TUI; `Ctrl+Space` again unlocks |
156+
157+
### No leader needed
110158

111-
### Other
159+
These work everywhere, no mode required:
112160

113161
| Keybinding | Action |
114162
|------------|--------|
115-
| `Ctrl+A, [` | Enter copy mode (vim navigation) |
116-
| `Ctrl+A, Shift+R` | Reload config |
163+
| `Alt + h/j/k/l` | Move focus (hops between tabs at the edges) |
164+
| `Alt + =` / `Alt + -` | Grow / shrink pane |
165+
166+
> `Alt+hjkl` moves between zellij **panes**. Inside Neovim, use Neovim's own
167+
> `Ctrl+hjkl` to move between **its** splits (see the Neovim section); the two
168+
> navigation layers are kept separate by design.
169+
170+
---
171+
172+
## workmux (parallel agents)
173+
174+
[workmux](https://github.com/raine/workmux) orchestrates parallel agents on top of
175+
zellij: each `workmux add` creates a git worktree and opens it as **its own zellij
176+
tab** running the agent. So workmux has no separate keybindings — you navigate its
177+
worktrees with the zellij tab keys above (`Ctrl+Space [` / `]` or `Ctrl+Space 1-9`).
178+
179+
| Command (run in any pane) | Action |
180+
|---|---|
181+
| `workmux add <branch> "<prompt>"` | New worktree + zellij tab running the agent |
182+
| `workmux list` | Show worktrees + agent status |
183+
| `workmux merge <branch>` | Merge and clean up |
184+
| `workmux remove <branch>` | Remove without merging |
185+
| `workmux dashboard` | Live control center (the default `devc` layout) |
117186

118187
---
119188

@@ -196,11 +265,14 @@ Neovim uses LazyVim with **Space** as the leader key.
196265

197266
| Keybinding | Action |
198267
|------------|--------|
199-
| `Ctrl+H/J/K/L` | Navigate windows |
268+
| `Ctrl+H/J/K/L` | Navigate Neovim windows |
200269
| `Space w` | Window menu |
201270
| `Space -` | Horizontal split |
202271
| `Space \|` | Vertical split |
203272

273+
> `Ctrl+hjkl` moves between Neovim's own splits; `Alt+hjkl` moves between zellij
274+
> panes (see the zellij section). They're separate layers by design.
275+
204276
### Buffers
205277

206278
| Keybinding | Action |

build_files/hypercube/03-hypercube-configs.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ sed -i 's|^SHELL=.*|SHELL=/usr/bin/fish|' /etc/default/useradd 2>/dev/null || \
2626
# Starship config is read from STARSHIP_CONFIG env var set in fish config
2727
# Config lives at /usr/share/hypercube/config/starship/starship.toml (read-only)
2828

29+
### zellij multiplexer - system default config
30+
# zellij doesn't honor XDG_CONFIG_DIRS (like fish), so its config can't live in
31+
# /usr/share/hypercube/config. Install the unified keymap as the system default
32+
# at /etc/zellij; users override it at ~/.config/zellij/config.kdl.
33+
install -Dm644 "${CONFIG_DIR}/zellij/config.kdl" /etc/zellij/config.kdl
34+
2935
### Ghostty terminal - stub that sources system config
3036
# Users can customize by adding settings after the config-file line
3137
mkdir -p /etc/skel/.config/ghostty

build_files/hypercube/99-tests.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ REQUIRED_FILES=(
4747
"/etc/greetd/config.toml"
4848
# Config files
4949
"/etc/fish/config.fish"
50+
"/etc/zellij/config.kdl"
51+
# zellij ships as a static binary (not an rpm), so check the file directly
52+
"/usr/bin/zellij"
5053
"/usr/share/hypercube/config/starship/starship.toml"
5154
# Theming
5255
"/usr/share/themes/Tokyonight-Dark/gtk-3.0/gtk.css"

build_files/hyprland/01-hyprland-desktop.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,22 @@ dnf5 -y install \
5656
### Fish Shell (set as default)
5757
dnf5 -y install fish
5858

59+
### Terminal multiplexer - zellij
60+
# zellij isn't packaged in the ublue/Fedora repos (`dnf install zellij` ->
61+
# "No match"), so install the official static musl binary. The base image is
62+
# amd64-only (no platform matrix in build.yml), so x86_64 is sufficient. Pinned
63+
# for reproducibility. Gives the host the same Ctrl+Space multiplexer keymap
64+
# (installed to /etc/zellij/config.kdl by 03-hypercube-configs.sh) as devcube.
65+
#
66+
# TODO(#198): a COPR spec now exists at packages/zellij/zellij.spec. Once the
67+
# `zellij` package is configured + built in the binarypie/hypercube COPR (already
68+
# enabled in base/01-base-system.sh), replace this block with `dnf5 -y install
69+
# zellij` and move the test in hypercube/99-tests.sh back to REQUIRED_PACKAGES.
70+
ZELLIJ_VERSION=0.44.3
71+
curl -fsSL "https://github.com/zellij-org/zellij/releases/download/v${ZELLIJ_VERSION}/zellij-x86_64-unknown-linux-musl.tar.gz" \
72+
| tar -xz -C /usr/bin zellij
73+
chmod 0755 /usr/bin/zellij
74+
5975
### Terminal - Ghostty (from scottames COPR)
6076
dnf5 -y copr enable scottames/ghostty
6177
dnf5 -y install ghostty

dot_files/devcube/Containerfile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,17 @@ RUN fish_bin="$(command -v fish)" \
289289
&& usermod --shell "$fish_bin" root
290290

291291
# =============================================================================
292-
# LAYER 15: Entrypoint
292+
# LAYER 15: Session helpers
293+
# =============================================================================
294+
# devcube-session: the attach-or-create launcher the `devc` wrapper runs as the
295+
# container command, so closing + reopening lands back in the same workspace.
296+
# zj-quit: the floating "Save / Discard / Cancel" quit prompt (Ctrl+Space q).
297+
COPY devcube/devcube-session.sh /usr/local/bin/devcube-session
298+
COPY devcube/zj-quit.sh /usr/local/bin/zj-quit
299+
RUN chmod +x /usr/local/bin/devcube-session /usr/local/bin/zj-quit
300+
301+
# =============================================================================
302+
# LAYER 16: Entrypoint
293303
# =============================================================================
294304
COPY devcube/entrypoint.sh /usr/local/bin/devcube-entrypoint
295305
RUN chmod +x /usr/local/bin/devcube-entrypoint

dot_files/devcube/Justfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ test-build: build
7575
podman run --rm {{local_image}} workmux --version
7676
podman run --rm {{local_image}} starship --version
7777
podman run --rm {{local_image}} fish --version
78+
# Session helpers must be on PATH and executable inside the container.
79+
podman run --rm {{local_image}} sh -c 'command -v devcube-session && command -v zj-quit'
7880
# The entrypoint must pin $SHELL to fish so workmux drops new worktrees into
7981
# fish (its default-shell fallback is /bin/sh otherwise).
8082
podman run --rm {{local_image}} sh -c '[ "$SHELL" = "$(command -v fish)" ]'

dot_files/devcube/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,31 @@ workmux merge <branch> # merge and clean up
5656
workmux remove <branch> # remove without merging
5757
```
5858

59+
Each `add` opens a new zellij **tab**, so switch between agents with the
60+
multiplexer leader: `Ctrl+Space` then `[` / `]` or `1``9`. zellij uses a single
61+
leader-entered mode for everything (panes, tabs, sessions) — full keymap in
62+
[KEYBINDINGS.md](../../KEYBINDINGS.md).
63+
5964
Worktrees are created on a **per-project named volume** mounted at `/worktrees`,
6065
so they persist across restarts and stay isolated from other projects. The
6166
volume name is derived from the launch path, so `~/Code/myrepo` always gets the
6267
same worktrees back. (Worktrees live on the volume, not on the host — manage
6368
them from inside `devc`, not via `git worktree` on the host.)
6469

70+
### Closing & resuming
71+
72+
`Ctrl+Space q` opens a floating prompt to leave the devcube and drop back to your
73+
host shell:
74+
75+
- **Save** keeps the session. zellij serializes it to the persisted home volume,
76+
and the next `devc` from the same project reattaches to it — same tabs,
77+
worktrees, panes, cwds, and scrollback.
78+
- **Discard** deletes the session, so the next `devc` starts fresh.
79+
- **Cancel** stays put.
80+
81+
The session name is stable per project (derived from the launch path), which is
82+
what lets `devc` find and resume the saved session.
83+
6584
## How it works
6685

6786
`scripts/devcube.sh` runs the image as an ephemeral `podman` container and mounts
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env bash
2+
# devcube-session — attach-or-create a named zellij session inside the container.
3+
#
4+
# Runs INSIDE the devcube container (the `devc` wrapper invokes it as the
5+
# container command), where the zellij sessions actually live. Given a stable
6+
# per-project session name it either:
7+
#
8+
# * resurrects/reattaches an existing session of that name (one left behind by
9+
# Ctrl+Space q -> Save, serialized under the persisted /root home volume), or
10+
# * starts a fresh session with the requested layout when none exists.
11+
#
12+
# This is what makes "close the devcube, reopen it later, same workspace" work.
13+
#
14+
# Why not `zellij --session NAME --layout L`? With --session present, --layout is
15+
# interpreted as "add these tabs to the (not-yet-existing) session" and zellij
16+
# errors with "There is no active session!". The flag that always starts a fresh
17+
# named session with a layout is --new-session-with-layout, used below.
18+
set -euo pipefail
19+
20+
session="$1"
21+
layout="${2:-}"
22+
23+
# A session of this name already exists (running or serialized/resurrectable)?
24+
# `list-sessions` prints the name as the first field for both; awk matches it
25+
# exactly. With no sessions it errors to stderr (suppressed) and prints nothing.
26+
if zellij list-sessions --no-formatting 2>/dev/null |
27+
awk -v s="$session" '$1 == s { found = 1 } END { exit !found }'; then
28+
exec zellij attach "$session"
29+
elif [ -n "$layout" ]; then
30+
exec zellij --session "$session" --new-session-with-layout "$layout"
31+
else
32+
exec zellij --session "$session"
33+
fi

dot_files/devcube/scripts/devcube.sh

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ zellij | workmux)
8181
slug="$(basename "$project_dir" | tr -c 'a-zA-Z0-9_.-' '-')"
8282
phash="$(printf '%s' "$project_dir" | cksum | cut -d' ' -f1)"
8383
wt_volume="${DEVCUBE_WT_PREFIX:-devcube-wt}-${slug}-${phash}"
84+
# Stable, per-project zellij session name so closing the devcube (Ctrl+Space
85+
# q -> Save) and reopening it resumes the SAME session. The home volume
86+
# (/root) is shared across every project, so the name must be unique per
87+
# project -> include phash. zellij session names can't contain '.', so the
88+
# slug's dots are squashed to '-' (the volume name keeps them; it's separate).
89+
session="devcube-$(printf '%s' "$slug" | tr '.' '-')-${phash}"
8490
extra=(
8591
# Worktrees live here (podman auto-creates the volume on first mount).
8692
-v "${wt_volume}:/worktrees:rw"
@@ -89,15 +95,16 @@ zellij | workmux)
8995
# isolated from other projects and survives restarts.
9096
-e XDG_STATE_HOME=/worktrees/.local/state
9197
)
92-
# zellij 0.44.x fails ("There is no active session!") when --layout and
93-
# --session are passed together, so we never combine them. The session name
94-
# isn't load-bearing -- worktrees + workmux state persist via the volumes
95-
# above, not the zellij session -- so we let zellij auto-name it. workmux
96-
# operates on whatever the current session is, named or not.
98+
# Hand off to the in-image launcher, which resurrects the named session if it
99+
# exists (left by Ctrl+Space q -> Save) or creates it fresh otherwise. It has
100+
# to run inside the container because that's where the sessions live, and it
101+
# uses --new-session-with-layout (not --layout) to dodge zellij 0.44.x's
102+
# "There is no active session!" when --layout is combined with --session.
103+
# workmux operates on whatever the current session is.
97104
if [ "$TOOL" = workmux ]; then
98-
container_cmd=(zellij --layout workmux)
105+
container_cmd=(devcube-session "$session" workmux)
99106
else
100-
container_cmd=(zellij)
107+
container_cmd=(devcube-session "$session")
101108
fi
102109
;;
103110
esac

dot_files/devcube/zj-quit.sh

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
#!/usr/bin/env bash
2+
# zj-quit — the "quit devcube" prompt (bound to Ctrl+Space q in zellij).
3+
#
4+
# Launched in a floating zellij pane (see dot_files/zellij/config.kdl). It asks
5+
# whether to keep the session before dropping back to your host shell:
6+
#
7+
# Save -> Quit the session. With session_serialization on, zellij keeps it
8+
# as a resurrectable session; the next `devc` reattaches to it
9+
# (same tabs / worktrees / panes). See devcube-session.sh.
10+
# Discard -> Delete this session (and its serialized state) outright, so the
11+
# next `devc` starts fresh from the workmux layout.
12+
# Cancel -> Close this prompt and stay where you are.
13+
#
14+
# The devcube image ships fzf, which renders the menu full-screen in the
15+
# floating pane — arrow keys / type-to-filter, Enter to pick, Esc to cancel.
16+
set -euo pipefail
17+
18+
# zellij exports the live session name into every pane.
19+
session="${ZELLIJ_SESSION_NAME:-}"
20+
21+
choice="$(
22+
printf '%s\n' \
23+
'Save — keep this session; devc resumes it next time' \
24+
'Discard — delete this session; devc starts fresh next time' \
25+
'Cancel — stay here' |
26+
fzf --reverse --no-info --cycle \
27+
--pointer='' \
28+
--prompt='quit devcube ▸ ' \
29+
--header='Save your session before quitting?' ||
30+
true
31+
)"
32+
33+
case "$choice" in
34+
Save*)
35+
# Quit -> resurrectable (serialization is enabled in config.kdl).
36+
exec zellij action quit
37+
;;
38+
Discard*)
39+
# Kill the running session AND remove its serialized copy in one shot, so
40+
# nothing is left to resurrect. Fall back to a plain quit if, for whatever
41+
# reason, we don't know our own session name.
42+
if [ -n "$session" ]; then
43+
exec zellij delete-session "$session" --force
44+
else
45+
exec zellij action quit
46+
fi
47+
;;
48+
*)
49+
# Cancel / Esc / empty — just close the floating pane.
50+
exit 0
51+
;;
52+
esac

0 commit comments

Comments
 (0)