Heca takes its name from the Hecatoncheires — hundred‑handed giants of myth — because it lets you juggle terminals, editors, and tools in a single GPU‑accelerated window. Think prefix keys, smooth animations, and zero ambrosia required.
A GPU-native terminal workspace compositor for developers. Inspired by NIRI's scrollable-tiling model, powered by wgpu and cosmic-text.
Think tmux meets NIRI meets Neovide — all panes render in a single GPU-accelerated window with smooth animations, sharp fonts, and zero window seams.
- What It Does
- Tech Stack
- Requirements
- Installation
- Getting Started
- Keyboard Commands
- Configuration
- Actions System
- Keybindings
- Modes
- Spawning Applications
- Architecture
- Plugins (Planned)
- Roadmap
- License
heca is a workspace compositor — not a traditional terminal emulator or window manager. It hosts multiple content panes (terminals, Neovim, browsers) inside a single native window with a NIRI-inspired layout engine:
- Scrolling columns — Panes are arranged in horizontal columns that scroll smoothly. Focus left/right animates the view to the active column.
- Vertical stacks — Within each column, panes stack vertically with intelligent height distribution.
- Workspaces — Multiple workspaces stacked vertically, switched with animated transitions.
- Floating panes — Any pane can be detached from the scrolling layout and positioned freely.
- GPU-native chrome — Tab bar, sidebars, status bar, pane borders — all rendered in one GPU pass.
- Future pluggable chrome — the long-term architecture is evolving toward left/right/top/bottom chrome regions that host built-in containers first and WASM/plugin containers later.
Why not tmux + a terminal?
| tmux in a terminal | heca | |
|---|---|---|
| Rendering | CPU text grid | GPU text atlas + primitives |
| Fonts | Limited ligature support | Full HarfBuzz shaping, variable fonts |
| Animations | None | Smooth scroll, zoom, slide |
| Panes | Terminal only | Terminal, Neovim, browser, future container/plugin integrations |
| Chrome | Text-only | GPU-rendered shapes, shadows, rounded corners |
Why NIRI-inspired?
NIRI is a scrollable-tiling Wayland compositor. heca replicates its core layout principles:
- Opening a new window does not affect sizes of existing windows.
- The focused window does not move around on its own.
- Column widths are NOT normalized — each column keeps its own width.
- Only the active column's width changes on resize.
- Horizontal scrolling with animated snap when switching focus.
| Layer | Technology | Purpose |
|---|---|---|
| Windowing | winit | Cross-platform window creation, input events |
| GPU API | wgpu | Vulkan/Metal/DX12/WebGPU abstraction |
| Text | cosmic-text | Font shaping, glyph atlas, ligatures |
| Terminal | vte + PTY | ANSI terminal emulation |
| Config | serde + TOML | User configuration |
| Language | Rust | Memory-safe, zero-cost abstractions |
- Rust 1.80+ (install via rustup)
- GPU with Vulkan (Linux), Metal (macOS), or DX12 (Windows) support
- OS: Linux, macOS, or Windows
- macOS: Uses
WaitUntilevent loop control flow to avoid 100% CPU busy loops. Physical key fallback for emptykey_texton winit. - Linux: Native Wayland/X11 via winit. PTY spawning via
libc::openpty(). - Windows: wgpu DX12 backend. PTY via Windows ConPTY (planned).
# Clone
git clone https://github.com/ovidius72/heca.git
cd heca
# Build
cargo build --release
# Run
cargo run -p hecaOn first launch, heca creates a default config at:
- Linux/macOS:
~/.config/heca/config.toml - Windows:
%APPDATA%\heca\config.toml
The window opens with a single pane. All WM commands use a prefix key (Ctrl+B by default), like tmux:
Ctrl+B → h Focus left column
Ctrl+B → l Focus right column
Ctrl+B → Enter New column (horizontal split)
Ctrl+B → v New pane in column (vertical split)
Ctrl+B → x Close active pane
Ctrl+B → f Toggle floating
heca uses tmux-style prefix mode: press Ctrl+B, release, then press the action key. This avoids conflicts with applications running inside panes.
- Prefix timeout: 500ms — if you don't press a key, prefix mode exits automatically.
- Double prefix: pressing the configured prefix twice sends the literal configured prefix key to the focused pane (for example
Ctrl+B Ctrl+B→Ctrl+B,Ctrl+A Ctrl+A→Ctrl+A). - Bare modifiers: Holding Shift/Ctrl/Alt alone in prefix mode does nothing — wait for the actual key.
- Display symbol: in the UI (context menus, hints) the prefix is shown as the symbol
λ— e.g.prefix+frenders asλ f. This is display-only: config and keybinding strings keep the literalprefixtoken. The substitution lives in one helper,heca::shortcut::format_shortcut(keys, with_prefix)(change the symbol there).
| Command | Default Binding | Description |
|---|---|---|
| Focus left | h |
Activate column left, scroll view |
| Focus right | l |
Activate column right, scroll view |
| Focus up | k |
Activate pane above in column |
| Focus down | j |
Activate pane below in column |
| Next pane | ] |
Cycle to next pane across columns |
| Prev pane | [ |
Cycle to prev pane across columns |
| Workspace next | d |
Switch to next workspace (down) |
| Workspace prev | u |
Switch to prev workspace (up) |
| Focus toggle (local) | i |
Toggle between last two panes in same workspace |
| Focus toggle (global) | Shift+L |
Toggle between last two panes globally |
| Command | Default Binding | Description |
|---|---|---|
| Split horizontal | Enter |
New column to the right of active |
| Split vertical | v |
New pane below active in same column |
| Close | x |
Close active pane |
| Float | f |
Toggle pane between scrolling and floating |
| Pane select | q |
Overlay letters on panes; press letter to focus |
| Swap pane | Shift+Q |
Overlay letters; swap panes (stay at current position) |
| Swap and focus | m |
Overlay letters; swap panes (follow to destination) |
| Move column to workspace | c |
Overlay letters on workspaces; press letter to move the active column there |
| Move pane to workspace | g |
Overlay letters on workspaces; press letter to move the active pane there |
| Move pane to column | Ctrl+C |
Overlay letters on columns (any workspace); press letter to stack the active pane into that column |
| Command | Default Binding | Description |
|---|---|---|
| Resize increase | = |
Widen active column |
| Resize decrease | - |
Narrow active column |
| Pane height increase | Shift+= |
Grow active pane height |
| Pane height decrease | Shift+- |
Shrink active pane height |
Two explicit scopes (no implicit resolution): Ctrl = whole app (chrome/UI
font and every terminal), Ctrl+Shift = the terminal the keyboard is on
only (a docked terminal that holds it, else the focused pane's), layered on top of
the app-wide size. The same gesture is also on Ctrl/Meta+mouse-wheel,
resolved by what's under the pointer (over a terminal, in a pane or a dock → that
terminal; over chrome/empty → whole app). (Alt is avoided: on macOS the Option
key rewrites the typed character, so Alt+= never matches.)
| Command | Default Binding | Description |
|---|---|---|
| App font bigger (everything) | Ctrl+= |
Increase chrome/UI + every terminal pane |
| App font smaller (everything) | Ctrl+- |
Decrease chrome/UI + every terminal pane |
| App font reset (everything) | Ctrl+0 |
Reset to the configured sizes |
| Terminal font bigger | Ctrl+Shift+= |
Increase the font of the terminal the keyboard is on |
| Terminal font smaller | Ctrl+Shift+- |
Decrease the font of the terminal the keyboard is on |
| Terminal font reset | Ctrl+Shift+0 |
That terminal follows the app-wide size |
Sticky font-size modes (avoid re-pressing the chord): enter once, then tap keys
repeatedly. prefix+! → app font mode (whole app), prefix+@ → terminal font
mode (the terminal the keyboard is on). Inside either mode: k/↑ bigger, j/↓ smaller, 0 reset,
Esc/Enter exit. Like the built-in resize mode (prefix+r), fully rebindable.
| Command | Default Binding | Description |
|---|---|---|
| Move pane left | Ctrl+[ |
Move active pane to column on left |
| Move pane right | Ctrl+] |
Move active pane to column on right |
| Swap left | Ctrl+H |
Swap with pane to the left |
| Swap right | Ctrl+L |
Swap with pane to the right |
| Swap up | Ctrl+K |
Swap with pane above |
| Swap down | Ctrl+J |
Swap with pane below |
| Command | Default Binding | Description |
|---|---|---|
| Create workspace | w |
Create new workspace with a pane |
| Rename workspace | Shift+W |
Rename the active workspace (a focused container's cursor row has its own key — see below) |
| Rename pane | $ |
Rename the focused pane (likewise) |
| Rename column | Shift+C |
Rename the active column (likewise) |
| Toggle left sidebar | b |
Show/hide left sidebar |
| Toggle right sidebar | . |
Show/hide right sidebar |
| Sidebar focus | e |
Give the keyboard to the workspaces dock |
| Focus dock | Shift+E |
Letters over every dock; press one to give it keyboard focus |
| Collapse current workspace | < |
Collapse the active workspace tree row (UI only) |
| Collapse current column | ( |
Collapse the focused tiled column tree row (UI only) |
Today, sidebar navigation operates on the built-in workspace tree, which is a container (dock) mounted in a sidebar shell. Long-term the sidebar hosts several pluggable containers side by side.
Chrome keyboard focus is a dock, not a side. focus_dock (Shift+E) lights a letter over every
dock on screen and focuses the one you pick; the focused dock shows a focus ring, and keyboard
scrolling acts on it. Because focus is held by container id, moving a dock from one sidebar to the
other takes its focus with it — nothing in this path names left or right. sidebar_focus (e) is the
same idea aimed at navigation: it focuses the dock that has keyboard navigation of its own, reveals
whichever region that dock is seated in, and enters nav mode. With no navigable dock mounted it does
nothing (it will not expand an empty sidebar to show you a blank frame).
The dock can also be named, which skips the pick: focus_dock workspaces over RPC, or a mode binding
carrying args = { dock = "workspaces" } (no flat binding form takes args yet). It is one action
either way — the pick is only how a keyboard supplies an argument it cannot type. Aiming it at the
dock that already has focus is the way back out, so one key both takes the keyboard and gives it
back; unfocus_dock (RPC) and Esc do the same thing explicitly.
Focus is the mode. While a dock holds chrome focus the keyboard is redirected to it — there is no separate mode to enter, because focus already answers where the keys go. Concretely:
- unprefixed keys resolve in the focus layer (below) instead of being forwarded to the terminal;
- an unbound key while a dock is focused does nothing — it is swallowed, never leaked into the shell behind the dock;
prefix+…keeps working exactly as it does otherwise, and falls through to the focus layer when the global map has no binding for the key;- the focused pane is not changed. Only the keyboard moves, so
prefix+Enterstill splits the pane you last worked in; - the status bar shows the dock's name where it would say
NORMAL, so a swallowed key is never silent about where it went. The focus ring is the other half of that.
Two layers are consulted while a dock is focused, in this order:
- the surface's own —
[[keys.surface]], below (this placement first, then the component as a whole); - the focus layer — a built-in mode keymap,
[[keys.mode]] name = "focus", overridable inkeybindings.tomllike any other. It carries what the widgets answer — paging and edges for whatever scroll area the focused container nests — deliberately not a per-container vocabulary: a scroll region behaves the same wherever it is mounted, so nothing has to declare it, and a container with nothing scrollable simply declines and the key does nothing.
A surface declares the actions only it can do; the keys for them live in a [[keys.surface]]
entry. name says which surface — a field, not the table name, so a surface may be called
unbind or widgets without colliding with a config keyword. An optional id narrows the entry to
one placement, layered over the id-less one, so two seatings can differ while cursor, scroll position
and focus are per placement anyway.
A dock and an overlay are the same thing to the keyboard: something that holds it for a while
and answers keys of its own. So an overlay declares its keys the same way, naming itself by the
addressable name show_layer and hide_layer use:
[[keys.surface]]
name = "heca.expose" # the pane map
delete_pane = "x" # the card the cursor is on
delete_column = "r" # the column that card sits in
delete_workspace = "d" # the workspace that column sits inWhile a surface holds the keyboard, the app's own bindings behind it are refused — prefix+j does
not move the pane behind the map — except for actions that are app-level in every context
(reload_config, close_overlay, the prefix+/ picker, prefix+>). Which those are is a property
of the action, not of the binding, because the same action is reached from a key, a menu entry,
the palette, a button and RPC.
A way out of a surface is declared once for every surface of that kind, not per surface: the
[[keys.mode]] name = "layer" block is the floor every overlay answers (Escape, q and
Ctrl+q as shipped), and name = "focus" is the same thing for a focused dock (Escape). Declared there they
exist only while that kind of surface holds the keyboard, so the program running in a pane keeps
those keys — :q still quits vim, and Ctrl+q still reaches readline. That is what makes them
different from the global [keys] map, which is consulted whether or not anything is in front.
[[keys.component]]is the older spelling of this block and still works unchanged — the two are read as one list. New entries should use[[keys.surface]].
Binding names are short: under name = "docker", restart_selected means the action id
docker.restart_selected. You never repeat the component on every line of its own block. An id heca
already knows keeps its own name — a component binds existing actions rather than redeclaring them.
[[keys.surface]]
name = "docker"
restart_selected = "r" # → docker.restart_selected
next_pane = "n" # …an EXISTING action id, simply bound here — never redeclared
[[keys.surface]] # the same surface, one placement only
name = "docker"
id = "docker.right"
restart_selected = "R" # everything else is inherited from the entry above
[[keys.surface.bind]] # the arg-carrying form
action = "spawn_command"
keys = "t"
args = { command = "lazydocker", float = "true" }
[keys.surface.unbind] # explicit removal, keyed by the combo
"s" = trueMerge rules, and why the two forms differ. A TOML table already merges per key, so changing one
action = "key" entry keeps every other default. An array is replaced wholesale, which for
[[keys.surface.bind]] would mean adding one binding silently drops every shipped default — so
those merge by their keys field instead: a keymap is a map from combo to action, so merging on
the combo is the ordinary table rule applied to what the array is really keyed by. unbind is applied
last and keyed by the combo, so it retires a binding whatever it points at.
Where the defaults are. heca's own components ship their keys in keybindings.default.toml, in
this same shape, at the bottom of the file — one place a key is written and one place to change it. A
plugin has no entry in that merge, so it registers its keys at runtime instead; whatever your
config says still wins, and a registration never overwrites a combo you bound or gives a rebound
action a second key. Binding an id whose component is not mounted is not an error: like every binding
it resolves at press time, and simply does nothing until that component appears. Nor is an id for a
placement that never exists.
heca's own workspaces component ships these in keybindings.default.toml. They apply only while
that dock holds the keyboard (prefix+e, or click it), so they need no prefix and no mode.
| Key | Action | What it acts on |
|---|---|---|
k / j (also Up/Down) |
cursor_up / cursor_down |
the dock's own cursor |
h (also Left) |
collapse_row |
fold the row, or step out to its parent |
l / Enter (also Right) |
activate_selected |
focus the row and hand the keyboard back |
Space |
peek_selected |
focus the row, keep the keyboard on the dock |
Tab |
toggle_selected |
fold / unfold a structural row |
w / p / c |
create_workspace / create_pane / create_column |
a new one where the cursor is |
r |
rename_selected |
rename the row under the cursor — a pane or a workspace |
x |
delete_selected |
delete the row under the cursor |
Shift+X |
delete_selected_column |
the cursor's whole column |
z |
zoom_selected |
zoom / unzoom the cursor's column |
The cursor-row verbs (r, x, …) are the component's, because "the row my cursor is on" is a
fact nothing outside the component can know. The app's own prefix+$ / prefix+Shift+w are a
different question — the focused pane and the active workspace — and keep that meaning wherever the
keyboard is. Everything else here binds an action that already exists rather than redeclaring it.
A container does not have to declare anything to be usable:
-
global_focus— written in its[[keys.surface]]entry, but it applies while the container does not have focus, which is the only time it is useful. heca therefore binds it in the global map rather than the container's own layer. With anidit aims at that seating; without one it names the component and lands on the seating you were last in. Pressing it again while that container holds the keyboard gives it back. The name is reserved — a component cannot have an action calledglobal_focus. -
Escape— gives the keyboard back to the main region. Bound for every container as shipped (thefocusblock ofkeybindings.default.toml), so a dock that declares nothing is still leavable without the mouse. Every panel always has a way out, and by default it isEscape: to move it, bindunfocus_dockto another key (unfocus_dock = "prefix+Escape", or in the block) and giveEscapeup withunbind:[[keys.mode]] name = "focus" unbind = ["Escape"] [[keys.mode.bindings]] action = "unfocus_dock" keys = "Ctrl+g"
A config that leaves a floor with no key — in the block or behind the prefix — is refused for that floor,
Escapeis put back, and the start-up report says so. WithEscapegiven up, it reaches whatever is typed into inside the dock (a terminal's program, say) like any key nobody claimed. The same holds for overlays (name = "layer",close_overlay).
Two components asking for the same global_focus combo is reported at startup like any other
collision.
Keys are no longer all in one file, so reading config can no longer answer "what runs this action":
a mode's keys are in [[keys.mode]], a surface's in [[keys.surface]], and a plugin's are in no
file at all. heca --keys-show prints every binding name, the key it resolves to and the layer it
came from — read out of the built keymaps, so it sees all of them alike. --json emits the same
three facts per line for scripting.
$ heca --keys-show
BINDING KEY LAYER
close prefix+x [keys]
workspaces.cursor_down j [[keys.surface]] workspaces
docker.restart r plugin
| Key | Action |
|---|---|
PageUp / PageDown |
Scroll the focused dock one page up / down |
Home / End |
Jump the focused dock to top / bottom |
Alt+PageUp / Alt+PageDown |
Scroll the focused dock one page left / right |
Alt+Home / Alt+End |
Jump the focused dock to its left / right edge |
Esc |
Give the keyboard back to the focused pane |
The bare keys are free here precisely because nothing is being forwarded to a backend. The Shift+
scroll bindings under "Direct (non-prefix) keybindings" are unchanged and, while a dock is focused,
aim at the dock too — one binding, one meaning: scroll whatever has the keyboard. Horizontal
scrolling exists only here (scroll_page_left, scroll_page_right, scroll_to_left_edge,
scroll_to_right_edge, also reachable over RPC by the same names), because a
terminal viewport has a single axis.
There is no sidebar mode. Which dock the keyboard is aimed at is a container id held in the
chrome store, not an input mode, so nothing has to be entered or left: prefix+e (or a click) gives
the keyboard to the workspaces dock, Esc gives it back, and while it is there the dock's own keys
apply — the table above, rebindable in [[keys.surface]].
l / Right / Enter focus the row and hand the keyboard back; Space focuses it and keeps
the keyboard on the dock, so you can keep walking with j/k and preview each row. The mutation
keys act on the row the cursor is on; the global collapse bindings (< and () act on the active
main-view workspace/column instead and do not touch the dock.
A click does the same thing as a key. Clicking anywhere in a container gives it the keyboard and
moves its cursor to the row you clicked — so j continues from there — and clicking outside every
container gives the keyboard back. Right-clicking a row opens that row's menu, and aims the keyboard
the same way first.
heca provides a host-managed scrollback viewport — when you scroll up at a normal shell prompt, you are scrolling the host's viewport into terminal history, not sending escape sequences to the PTY. This gives you tmux-style scrollback navigation without a terminal multiplexer.
Mouse-grab awareness: when a TUI program (vim, htop, less, lazygit) enables mouse reporting, the host automatically forwards wheel events to the program. Shift+wheel always scrolls the host viewport, bypassing any mouse grab.
Selection mode is heca's copy/scrollback mode (tmux copy-mode style), entered
with prefix+s (caret stays where the terminal cursor was). It is also entered
automatically when you scroll up with the wheel at a non-grabbed prompt.
Inside Selection mode:
| Key | Action |
|---|---|
h / l / j / k or arrow keys |
Move caret left / right / up / down (the viewport auto-scrolls at edges) |
PageUp / PageDown |
Move caret up / down by one page |
u / d |
Scroll viewport up / down by half-page |
Ctrl+u / Ctrl+d |
Scroll viewport up / down by one full page |
g |
Jump to top of scrollback history |
Shift+g |
Jump to the live bottom (stay in selection mode) |
v / Space |
Begin selection (toggle highlighting) |
o |
Toggle selection endpoint |
Shift+o |
Open the hyperlink under the caret |
y |
Copy selection to clipboard |
/ |
Search the scrollback — type the query (matches highlight live, the caret jumps to the nearest); Enter keeps the matches, Esc cancels |
n / Shift+n |
Jump to the next / previous search match (after a / search) |
Esc |
Exit selection mode (snap to bottom + clear selection) |
Wheel:
- At a normal shell prompt → scrolls host scrollback viewport by
terminal_wheel_scroll_linesrows per notch (configurable). - Over a mouse-grabbed TUI (vim, htop, less, etc.) → forwarded to the terminal as mouse events.
- Shift+wheel → always scrolls host viewport, bypassing any mouse grab.
- The wheel only scrolls — it never switches to Selection mode. Typing snaps the view back to the
bottom;
prefix+senters Selection mode. - Shift+drag selects with the mouse and copies on release, then clears the highlight — the same
as
yin Selection mode. It does not enter Selection mode. - Alternate-screen TUIs (
nvim,less, …): while a program owns the alternate screen, the host has no exposed scrollback history to scroll into. Plain wheel is forwarded to the program in that case (solesscan still react when it isn't mouse-grabbed);Shift+wheelis a no-op because it bypasses the program and there is nothing to scroll host-side. This is an inherent limitation — wezterm preserves the pre-alt-screen history but does not expose it while the alternate screen is active.
Relevant settings:
terminal_scrollback_lines— total host-retained history capacity (rows above the live viewport).terminal_mouse— whentrue, wheel input controls host scrollback unless the terminal app has grabbed the mouse.terminal_wheel_scroll_lines— rows moved per wheel notch.terminal_scroll_animations— enable/disable backend-side easing for discrete terminal viewport jumps in Normal mode (for exampleShift+PageUp/PageDown,Shift+Home/End, and the scrolled-up badge click).falsemakes those jumps immediate.
For users who prefer not to use prefix mode, heca provides direct bindings that work without the prefix key. These are intercepted before reaching the terminal:
| Combo | Action |
|---|---|
Shift+PageUp |
Scroll up by one page (repeatable, no selection mode entry) |
Shift+PageDown |
Scroll down by one page (repeatable) |
Shift+Up |
Scroll up by a few lines (repeatable) |
Shift+Down |
Scroll down by a few lines (repeatable) |
Shift+Home |
Jump to the top of scrollback (repeatable) |
Shift+End |
Jump to the live bottom (repeatable) |
These are intercepted as global keybindings before reaching the terminal.
They stay in Normal mode, so holding the key repeats the scroll without
entering Selection mode. Page jumps and top/bottom jumps honor
terminal_scroll_animations; line steps remain immediate.
While a chrome dock holds keyboard focus these same four page/edge bindings are aimed at the dock
instead of the pane — see "Chrome keyboard focus is a dock, not a side" above. The line steps
(Shift+Up / Shift+Down) stay with the pane: a scroll area answers pages and edges, not lines.
Terminal panes with scrollback history show two GUI affordances (theme-driven,
configurable under [appearance.terminal] show_scrollbar):
- Scrollbar — a draggable accent thumb on the right edge of the pane. Drag
it to jump to an arbitrary viewport position; clicking the track jumps there
immediately. The thumb size reflects how much history is visible. Visibility:
always— always shown when there is scrollable history.when_needed(default) — shown only while scrolled away from the live bottom.never— never shown.
- Scrolled-up badge — a clickable chip reading
N lines abovethat appears at the top-right of the pane while the viewport is scrolled up. Clicking it jumps back to the live bottom and honorsterminal_scroll_animations.
| Command | Default Binding | Description |
|---|---|---|
| Command palette | p |
Open command palette |
| Reload config | Shift+R |
Reload config.toml at runtime — a toast confirms success, or reports the parse error with a Retry button (the working config is kept on failure; the error also prints to stderr) |
Every action heca knows about, searchable from one key — including those a mounted component contributes, which are listed under that component's name and focus it before running.
- Fuzzy, smart-case matching. Type lowercase and case is ignored; type an
uppercase letter and it starts to matter. Set
search_caseto"sensitive"or"insensitive"inconfig.tomlif you'd rather it never guessed. - It learns what you use. Commands you pick often — and recently — rise to the top, so an empty palette shows your habits rather than an alphabet. Running something a second time counts for more than having run something else a moment ago.
- Past searches come back.
Ctrl+p/Shift+↑walks back through what you searched for,Ctrl+n/Shift+↓forward; step past the newest and whatever you were part-way through typing is handed back to you. - It remembers across restarts, in a small file under your data directory. Delete
it whenever you like — you lose nothing but the convenience — or set
search_history = falseto keep nothing at all.search_history_sizeandsearch_usage_sizedecide how much is kept. - Several heca windows share it without trampling each other: a save merges with what is already on disk rather than replacing it, the way neovim's shada file works. Two windows both using a command each add to its count.
- Forget it whenever you like.
Clear Search Historydrops the queries you have typed,Clear Search Rankingdrops what heca has learned about your habits — both from the palette itself, or over RPC. - The selected row shows its full description, wrapped, and the rows below make room for it as you move.
heca loads config from ~/.config/heca/config.toml (general settings) and
~/.config/heca/keybindings.toml (keybindings) — %APPDATA%\heca\… on Windows.
Both are optional and deep-merged over the built-in defaults, which live in two
versioned, embedded files (the single source of truth — copy and edit them):
config.default.toml— default[settings],[appearance],[font],[program]keybindings.default.toml— default[keys](prefix, bindings, modes)
A .heca/config.toml in the folder heca was started in (or the nearest parent that has a .heca/
folder) is merged over your config key by key: any key it sets wins, tables merge, and nothing
else changes. It takes the same keys as config.toml — a theme colour, a key binding, anything. A
project file with an error is reported and left out, and your configuration stands. prefix+Shift+r
reloads it with the rest. With no .heca/, nothing changes.
A project file is ignored until you trust it — it ships with the repository, so a cloned one could
bind a key to a command. heca says so, and heca --trust (in the project, or heca --trust <folder>)
trusts it as it is now; if the file changes, it is ignored and you are asked again. heca --untrust
forgets it, and heca --show-config marks an untrusted file "(not trusted, ignored)".
# Prefix key (default: "ctrl+b")
prefix = "ctrl+a"
# Theme
# Built-in: "mocha" (dark), "latte" (light)
theme = "mocha"
# Window settings
[settings]
window_width = 1280
window_height = 800
mouse = true
shell_integration = true
# Keybindings — prefix+ syntax for prefix bindings, direct for global
[keys]
prefix_timeout_ms = 1000 # how long the prefix waits for the next key
focus_left = ["prefix+h", "prefix+ArrowLeft"]
focus_right = ["prefix+l", "prefix+ArrowRight"]
focus_up = ["prefix+k", "prefix+ArrowUp"]
focus_down = ["prefix+j", "prefix+ArrowDown"]
split_horizontal = "prefix+Enter"
split_vertical = "prefix+v"
zoom_column = "prefix+z"
close = "prefix+x"
float = "prefix+f"
pane_select = "prefix+q"
swap_pane = "prefix+Shift+m"
swap_and_focus_pane = "prefix+m"
move_column_to_workspace_pick = "prefix+Shift+g"
move_pane_to_workspace_pick = "prefix+g"
move_pane_to_column_pick = "prefix+Ctrl+c"
move_pane_to_new_column = "prefix+Shift+Enter"
rename_workspace = "prefix+Shift+w"
rename_pane = "prefix+$"
rename_column = "prefix+Shift+c"The config uses TOML with a flat [keys] table for simple actions, plus structured tables for richer bindings:
[keys]
# Single binding
focus_left = "prefix+h"
# Multiple bindings (list syntax)
focus_left = ["prefix+h", "prefix+ArrowLeft"]
# Global binding (no prefix — works in Normal mode)
# These are checked before forwarding to terminal
Alt+Enter = "spawn_lazygit" # Requires [[keys.command]]
# Unbind defaults
[keys.unbind]
"prefix+f" = true # Remove float toggleEvery one of these answers and exits before the window opens, so a script can ask without a display:
heca -h --help # these commands
heca -k --keys-show [--json] # every binding name and the key it resolves to
heca -a --list-actions [--json] # every action, its category, and how many arguments it takes
heca -d --describe-action resize # one action in full: what it does, every argument, every value
heca -d resize -j # the same, as JSON (-j is --json)Each command has a one-letter form. One letter, not two: -la would read as the combined -l -a
everywhere else on the machine, and a flag that looks like a convention it does not follow is worse
than no short form at all.
--describe-action is the reference for an action's arguments — including which are optional and
what values an enum accepts — because the declaration lives beside the parser, so the two cannot
drift. --help is rendered from the same table that dispatches, so it cannot list a command that
does nothing or miss one that works.
📖 docs/keybindings.md is the keybinding guide — the five binding shapes, which surface answers a key, and worked examples. Read that first; this section is the reference for one of the shapes.
The flat form under [keys] is action = "combo" — a single string, so it carries no arguments.
That is the only thing it cannot do, and [[keys.bind]] is the same normal-mode keymap written
long-hand, with args:
[[keys.bind]]
keys = "prefix+z"
action = "zoom_column"
# Open a pane running a command. `command` is the whole command line.
[[keys.bind]]
keys = "prefix+Shift+g"
action = "spawn_command"
args = { command = "lazygit", kind = "terminal", float = "true", close_pane = "true" }Mode bindings take the same action + keys + args shape, inside a mode:
[[keys.mode]]
name = "spawn"
trigger = "prefix+s"
sticky = true
[[keys.mode.bindings]]
action = "spawn_command"
keys = "t"
args = { command = "btm", float = "true" }For launching a program there is also [[keys.command]], which says the same thing without naming
an action:
[[keys.command]]
keys = "prefix+Ctrl+g"
command = "lazygit"
kind = "terminal" # terminal | app | plugin
float = true
close_pane = true # close the pane when the command exits
keep_on_error = false # keep it open if the command failsspawn_command's arguments in full: command (required), kind, float, close_pane,
keep_on_error, keep_on_success. There is no size or position argument — a floating pane
takes its default geometry. See Planned: spawn_pane for the
sized/multi-kind spawn that has been designed and not built.
Complete example set:
[keys]
focus_left = "prefix+h"
float = "prefix+f"
# A unit action, long-hand
[[keys.bind]]
keys = "prefix+z"
action = "zoom_column"
# A tiled pane running a command
[[keys.bind]]
keys = "prefix+Ctrl+g"
action = "spawn_command"
args = { command = "lazygit" }
# The same, floating, closing itself when the command exits
[[keys.bind]]
keys = "prefix+Shift+t"
action = "spawn_command"
args = { command = "btm", float = "true", close_pane = "true" }
[[keys.mode]]
name = "spawn"
trigger = "prefix+s"
sticky = true
[[keys.mode.bindings]]
action = "spawn_command"
keys = "g"
args = { command = "lazygit" }
[[keys.mode.bindings]]
action = "zoom_column"
keys = "z"prefix+h → Press prefix, then h
prefix+Shift+q → Press prefix, then Shift+q
prefix+Ctrl+h → Press prefix, then Ctrl+h
Alt+Enter → Global Alt+Enter (no prefix)
Super+t → Global Super+t (no prefix)
F1 → Global F1
Modifiers: Ctrl, Shift, Alt, Super (also accepts Win, Cmd)
Named keys: Enter, Tab, Escape, Backspace, ArrowLeft, ArrowRight, ArrowUp, ArrowDown, Space, Delete
Built-in themes: mocha (dark), latte (light). Create custom themes in ~/.config/heca/themes/mytheme.toml:
name = "My Theme"
background = "#1e1e2e"
foreground = "#cdd6f4"
border = "#313244"
accent = "#89b4fa"
# Fonts are decoupled from the color theme (they are system-local, not
# theme-portable) — set them under [font] (see below), not here.
border_radius = 6.0
border_width = 1.0
# Sidebar drag-and-drop colors
sidebar_drag_ghost_bg = "#89b4faD9" # Ghost label background (accent @ 85%)
sidebar_drag_ghost_fg = "#ffffff" # Ghost label text (white)
sidebar_drag_source_bg = "#89b4fa26" # Source item background (accent @ 15%)
sidebar_drag_source_border = "#89b4fa" # Source item border (accent)
# Sidebar font sizes
sidebar_label_font_size = 14.0 # Workspace/column/pane labels
sidebar_button_font_size = 11.0 # [+w] [+c] [+p] [-] buttons
# Effect tokens (also overridable under [appearance])
glow_size = "medium" # none | thin | medium | large — glow presence + halo radius + strength
intensity = "medium" # off | low | medium | heavy — scanline/CRT overlay opacity only (NOT glow)
# Overlay panels (dialogs, dropdowns, context menus, command palette)
overlay_border_style = "bracketed" # bracketed | bordered | none — the shared frame around an
# overlay panel. `bracketed` = accent corner reticle (default),
# `bordered` = plain edge, `none` = no frame. Each widget's own
# accent border/glow is separate and unaffected.
# The two bars (px, clamped 16..=96) — only used while the bar is shown
# top_bar_height = 32.0 # the tab bar
# bottom_bar_height = 24.0 # the status bar
# The `prefix+/` picker's letters
hint_font_size = 12.0 # one size for EVERY letter in the app, in logical px. A letter is drawn
# over whatever it points at, so it is not scaled by the size of that
# thing — a header button and a whole pane wear the same letter.
# hint_color = "#ffb86c" # the letters' colour. Unset = the theme's own `hint_color`, which falls
# back to the accent. The colour lives in the theme so it travels with a
# palette; this overrides it for every theme you load.
# Keyboard focus outline
show_focus_border = true # draw the focus ring at all
# focus_ring = "#7fd3ff" # focus-outline color; unset = the accent shifted toward `foreground`
# (auto-brightens on dark themes, darkens on light themes). The
# destructive/danger focus ring always derives the same way.
# Sidebar highlight alphas (0.0–1.0; optional, shown with their defaults)
active_wash_alpha = 0.11 # Accent wash over the active workspace
card_background_alpha = 0.02 # Resting background tint of each pane card
drag_image_alpha = 0.6 # Opacity of the picture of a pane that follows the pointer while it is carried
drag_image_scale = 0.5 # Its size next to the pane (1.0 = life size)
[shadow]
color = "#000000"
alpha = 0.3
blur = 8.0Frosted-glass frost is heca-owned, not OS-dependent: heca renders a blurred vertical gradient as the bottom-most (z=0) layer, and panes composite translucently over it. This works identically on Linux, Windows, and macOS — no OS vibrancy required (vibrancy is an optional platform backdrop material, off by default).
[appearance] is organized as a flat app-wide section plus three nested
per-surface tables — [appearance.terminal], [appearance.pane],
[appearance.sidebar]. Border fields inherit: a surface value wins; unset →
the global [appearance] value; unset → the theme.
[appearance]
# z=0 background layer (the frosted gradient behind everything)
background_blur = 0 # 0..=100 — blur strength (0 = sharp gradient)
background_transparency = 0 # 0..=100 — 0 = opaque (default); >0 = translucent
# background_gradient_top = "#1e1e2e" # optional; unset → theme.background
# background_gradient_bottom = "#181825" # optional; unset → derived darker shade
[appearance.terminal]
# Tiled panes: frost = z=0 showing through the translucent terminal surface.
transparency = 0 # 0..=100 — surface alpha over z=0
# Floating panes: their own REAL blur of the tiled content behind them.
floating_transparency = 0
floating_blur = 0 # 0..=100
# Scrollback scrollbar visibility for terminal panes with history:
# always — always show the scrollbar
# when_needed — show only while scrolled away from the live bottom (default)
# never — never show
show_scrollbar = "when_needed"
# Scrolled-up indicator badge ("N lines above", click snaps to bottom).
# true | false (default true)
show_scrolled_up_badge = trueHow frost is produced:
- Tiled panes — set
background_blur > 0(frosts the gradient) +[appearance.terminal] transparency > 0(lets it show through the terminal surface). There is no per-terminal tiled blur knob — the z=0 layer is the frost source. - Floating panes — set
[appearance.terminal] floating_blur > 0+floating_transparency > 0; the blurred tiled content is stamped behind the floating pane at full opacity (no sharp text leak).
Migration: the old
terminal_blurandterminal_frost_colorknobs are removed. Usebackground_blurfor tiled frost strength. (terminal_bluronly ever tinted behind panes and could not blur the desktop; the z=0 layer is the real cross-platform fix.)
Reload any of these at runtime with prefix+Shift+r.
The two effect tokens live on the theme but are also overridable under [appearance] (unset → inherits the theme):
[appearance]
glow_size = "medium" # none | thin | medium | large — glow presence + halo radius + strength
intensity = "medium" # off | low | medium | heavy — scanline/CRT overlay opacity onlyThey are independent dimensions: glow_size is the sole owner of glow
(presence + radius + strength); intensity owns the scanline/CRT overlay
opacity only and does not affect glow (the older docs that said intensity
drove "glow + scanlines" were wrong). Reload at runtime with prefix+Shift+r.
[settings]
window_width = 1280 # Initial window width
window_height = 800 # Initial window height
mouse = true # Enable mouse interactions
focus_follows_mouse = true # Focus pane on hover
auto_scroll_edge = true # Auto-scroll near edges
edge_scroll_distance = 80.0 # How close (px) a dragged pane must come to the edge for that to start
edge_scroll_speed = 1000.0 # How fast (px per second) the view scrolls while a dragged pane is at the edge
column_focus = "last" # Pane focus lands on in the next column: "last" used there, or the one level with "row"
interactive_move_modifier = "Super" # Modifier for drag-and-drop
float_size = 0.95 # Share of the working area a pane takes when it floats with nowhere given
move_slide_reach = 0.9 # How far (fraction of the window) a moved or swapped pane animates in from; 0 = jump
shell_integration = true # Auto-inject OSC 133/OSC 7 shell hooks for runtime status + cwd
pane_renamed_add_process_name = true # Renamed pane shows its process name small, e.g. `MyPane (nvim)`
# pane_show_cwd is now [appearance.expose] show_cwd (still read; see below)
terminal_mouse = true # Enable host scrollback on wheel (vs forwarding to terminal)
terminal_wheel_scroll_lines = 3 # Rows per wheel notch when scrolling host viewport
terminal_scroll_animations = true # Smooth animated terminal viewport jumps
[settings.notification_system]
mode = "app" # "app" (in-app toast stack) | "system" (OS notifications —
# reserved, falls back to "app" until the backend exists) |
# "none" (no notifications at all)
auto_dismiss_ms = 4000 # How long an in-app toast stays before dismissing itself (ms).
# A notification a producer marks sticky, or one with its own
# lifetime, ignores this. Applies on prefix+Shift+r.
# The countdown pauses while the pointer rests on a card.
max_visible = 5 # How many toasts are on screen at once. The rest queue in the
# order they were raised — nothing is dropped — and each takes
# the slot that frees. Minimum 1.Destructive actions can pop a confirm dialog before running. This is declared
per action and configured in the generic [confirm] table, keyed by action
name — true prompts (Cancel / <action>), false runs immediately:
[confirm]
close = true # confirm before closing a pane
delete_column = true # confirm before deleting a column (and its panes)
delete_workspace = true # confirm before deleting a workspace (and its contents)The guard lives on the action, so every surface that triggers it — a
keybinding, the pane-header close button, a right-click menu, or RPC — confirms
identically. Any action (including a plugin's) is configurable by name here; an
action not listed uses its own declared default. Changes apply on
prefix+Shift+r.
Replaces the old
[settings] confirm_close_pane/confirm_delete_column/confirm_delete_workspaceflags — move any you had set into[confirm]asclose/delete_column/delete_workspace.The pane toggle was once
delete_pane; it still works, butcloseis the name now — one word for the action, its key, its label and its dialog. Close is for the pane you work in; Delete stays for a column or workspace, which destroys everything inside it.
heca shows short toast notifications in the top-right corner — a pane was created, a process exited, the config reloaded, a command could not be run.
[settings.notification_system]
mode = "app" # "app" (in-app toast stack) | "system" (OS notifications —
# reserved, falls back to "app" until that backend exists) |
# "none" (no notifications at all — stderr logs are unaffected)
auto_dismiss_ms = 4000 # how long a toast stays before dismissing itself
max_visible = 5 # how many are on screen at once; the rest queue- Severity is tone, not importance.
info/success/warning/dangerchange the accent colour; they do not change how long a toast stays. Every toast auto-dismisses afterauto_dismiss_msunless the code that raised it marked it sticky (a config-reload failure is sticky — you fix it and press Retry). - Interacting: click a toast's × or one of its buttons;
prefix+/puts pick letters on the visible toasts' buttons and their × , andprefix+ndismisses. Resting the pointer on a card holds the whole stack still, so nothing retires from under the click you are aiming at — the time that costs is given back when you move away, and each card resumes with what it had left. - More than fits: only
max_visiblecards show at once. The rest wait in the order they were raised — nothing is dropped. Closing one slides the cards after it up so the column never has a blank in it, and the next in line appears at the bottom, where a card that has just arrived belongs. - Raising one — from a keybinding, a script, or a plugin — is the
notifyaction:notify title="Build finished" body="3 warnings" severity=success. Rust code in the app usesNotification::info("…").body("…").send(). - What produces a toast today: a new pane, a pane whose process exited while the
pane stays open,
prefix+Shift+r(success or the parse error), and a command that fails to spawn.
Fonts are system-local, not theme-portable — a color theme that shipped a
font_family would break on a system without that font. So fonts live in a
dedicated [font] block, independent of the color theme: switching theme keeps
your fonts, and a theme never requires a specific installed font.
[font.family.ui]
normal = "Geist Mono" # UI/chrome font (omit → embedded Geist Mono fallback)
# bold = "..." # optional → falls back to normal
# italic = "..." # optional → falls back to normal + synthesized oblique
# bold_italic = "..." # optional → falls back to italic → bold → normal
[font.family.terminal]
normal = "Maple Mono Normal NF" # Terminal font (omit → embedded Maple Mono fallback)
# italic = "..." # optional distinct italic family
[font.size]
ui = 15.0 # UI/chrome font size (default 15.0)
terminal = 14.0 # Terminal font size (default 14.0)normal is optional — if omitted, the surface falls back to its embedded
font (Geist Mono for UI, Maple Mono Normal NF for terminal), so a theme never
depends on a system-installed font. bold / italic / bold_italic are
optional per-style family slots. When unset, the renderer falls back to
normal and selects the face via weight/style within the family (bold face via
Weight::BOLD; italic via a synthesized oblique when the family has no italic
face). When set, the renderer uses the named family for that style — so you can
point bold/italic at a different installed font without touching the regular
family.
The terminal shapes each row a run at a time (consecutive cells sharing a
font face are shaped together), so a coding font's OpenType ligatures and
contextual alternates — =>, ==, >=, !=, ->, … — render in terminal
panes. Glyphs are still snapped to the cell grid, and ligatures form across
foreground-color boundaries (each half keeps its own cell's color), matching how
kitty/WezTerm behave.
Which ligatures appear is up to the font — whatever your terminal font
ligates under standard shaping (calt/liga/clig); heca enables no font
features of its own. The embedded default, Maple Mono Normal NF, renders the
full set (=> -> == != >= |> …). To use a different coding font, point
the terminal family at any installed font:
[font.family.terminal]
normal = "JetBrainsMono Nerd Font Mono" # any installed system font is foundTo turn ligatures off entirely (each character renders standalone), set
ligatures = false under [appearance.terminal]:
[appearance.terminal]
ligatures = false # default true; disables calt/liga/clig for the terminal fontReload at runtime with prefix+Shift+r — the toggle and font both apply live.
OSC 8 hyperlinks emitted by programs are decorated in terminal panes. The
decoration and color are configurable under [appearance.terminal]:
[appearance.terminal]
hyperlink_style = "underline" # none | color | underline | undercurl (default underline)
# hyperlink_color = "#5fafff" # omit → theme accentnone leaves links looking like normal text; color recolors only; underline
and undercurl add a straight or wavy line in the link color. Plain-text URLs
(echo "https://…", log output) are auto-detected as links too; set
link_detection = false to mark only explicit OSC 8 links.
Opening links. Detected and OSC 8 links open the same way through the OS
handler (open / xdg-open / start):
- Mouse — hold the interactive-move modifier (Cmd by default) and click a link; the cursor turns into a pointer while it is held over one. A plain click still goes to the terminal program.
- Keyboard —
prefix+Shift+o(follow_link) labels every visible link across all on-screen panes with a letter; press the letter to open it, orEscto cancel. - Selection mode —
Shift+oopens the link under the caret (ostays the flip-endpoint binding). - Context menu — right-click a terminal pane for a menu with Open link
(shown only when the click cell is a link) plus pane actions (split, zoom,
float, close), each with its icon and keybinding hint. Click an entry or use the arrow
keys + Enter;
Escor an outside-click dismisses. (Right-clicking right on a resize seam still starts a right-drag resize.)
When shell_integration = false, heca spawns a bare interactive shell and you can source the generated snippets manually from ~/.config/heca/runtime/shell-integration/.
A terminal bell (\a) can drive up to three independent cues, configured under
[appearance.terminal]:
[appearance.terminal]
bell_attention = true # OS attention cue (Dock bounce / taskbar flash) while unfocused
bell_visual = false # brief accent flash over the content area
bell_audible = false # system beep (macOS only for now; no-op elsewhere)
bell_flash_alpha = 56 # how bright the visual bell's flash is at its peak, 0..=255
search_match_alpha = 64 # scrollback-search match highlight, 0..=255
search_current_match_alpha = 150 # the match the search is on (bolder)bell_attention only fires while the window is unfocused; the visual and audible
bells fire on any bell. All default off except bell_attention. Changes apply
live with prefix+Shift+r (no restart).
Each pane shows a small info bar along its top: configurable segments on the
left (what the pane is) and action buttons on the right. Both are configured
under [appearance.pane] as ordered lists — order in the list is the order shown
(left → right). An empty list hides that side; if both are empty the bar (and
its reserved space) disappears entirely.
When a pane is renamed (given a custom name), its sidebar card shows the custom
name followed by the process name in a small dimmed label — e.g. MyPane (nvim) — so the
sidebar keeps surfacing what's actually running. The command palette and the exposé name the
pane the same way. The process label is not part of the name
(it's never edited by rename); toggle it with [settings] pane_renamed_add_process_name
(default true). In the pane info bar, the app_name segment always shows the program
name regardless of a rename; add the pane_name segment to show the custom name there.
[appearance.pane]
# Left side — what to show, in order. A segment with no data for a pane is skipped
# (e.g. git segments outside a repo).
title_segments = ["location", "app_name", "git_branch", "git_status"]
# Right side — action buttons, in order. Each button's tooltip shows its real
# configured keybinding.
title_actions = ["split", "close"]
# The name drawn over a pane while it is dragged: a share of the pane's shorter side, kept
# between two sizes (px). And how wide the "new column" opening beside a column is (a share).
# drag_label_size_factor = 0.25
# drag_label_min_size = 24.0
# drag_label_max_size = 72.0
[appearance.sidebar]
# Sidebar shell appearance (independent of the panes; all optional):
width = 300 # Sidebar width in px (clamped 160..=560)
border_style = "bordered" # none | bordered | bracketed
border_width = 1.0 # frame width px; unset → global border_width
border_radius = 12.0 # corner radius px; unset → global/theme radius
border_color = "#40e0ff" # bordered-frame color; unset → global border_color
background_color = "#0b0f14" # shell fill; unset → theme sidebar surfaceWith border_style = "bordered", the frame is drawn at border_width in
border_color (which falls back to the global [appearance] border_color). With
"bracketed" it uses the theme accent corner-reticle, now sized by the same
border_width + border_radius. border_width = 0 removes the border.
Supported segments ([appearance.pane] title_segments):
| Value | Shows |
|---|---|
location |
Working directory (home-relative path) |
app_name |
Resolved program / app name (from the Process Catalog) — always the running program, never a rename |
pane_name |
The pane's own name: the custom rename when set, else the program name (custom-wins). Opt-in — not in the default segments |
git_branch |
Git branch — hidden outside a repo |
git_status |
Git change counts +A ~M -D — hidden when clean / no repo |
Default: ["location", "app_name"].
A program built on heca can add its own chips, named <extension>.<short> (for example
demo.status); they follow the same rule as the buttons below — shown after the defaults while this
list is still the default, only if you list them once you write your own, and reported once when one
you have not listed is available. A name nothing provides is reported and skipped.
Supported actions ([appearance.pane] title_actions):
| Value | Button does |
|---|---|
split |
Add a pane to the column |
close |
Close the pane |
zoom |
Toggle zoom (maximise) the column |
float |
Toggle floating for the pane |
move_left |
Move the pane left |
move_right |
Move the pane right |
Default: ["split", "close"] (move actions are omitted by default since panes are
already movable by mouse-dragging, but they remain valid config values). Each
button's tooltip shows the real configured keybinding for that action.
A program built on heca can add its own buttons, named <extension>.<short> (for example
demo.show_notes). They show after the defaults while this list is still the default; once you write
your own list, only the names you list show, and heca tells you once when a button you have not
listed is available. A name nothing provides is reported and skipped, with the nearest real one.
Lines under a pane's name in the sidebar ([appearance.sidebar] pane_lines, default
["cwd", "git"]): cwd is the working directory and git is the branch with change counts, shown
only inside a repository. A fresh install shows the folder line under each pane's name in the
sidebar. Which lines show is decided by this list alone. A program built
on heca can add its own, named <extension>.<short>; the same rules apply as for buttons and chips.
The exposé's folders are [appearance.expose] show_cwd (on by default; false hides them). It replaces
[settings] pane_show_cwd, which is still read while the new key is not written (once it is, it
decides), with a one-time notice to move it; it no longer affects the sidebar.
Every WM command in heca is an action. Actions are the core abstraction — everything from focusing a pane to resizing a column to spawning an application is an action.
Unit actions — Simple commands with no arguments:
focus_left,focus_right,split_horizontal,close,float
Parameterized actions — Commands with arguments:
FocusPane { pane_id }— Focus a specific pane by IDFocusWorkspace { ws_idx }— Focus a workspace by indexSwap { a_id, b_id }— Swap two panesResize { target, amount, edge }— Move a boundary of the focused column or pane. The target decides the axis;edgepicks which of its two edges moves (default: the one it already owned)SpawnCommand { command, kind, float, close_policy }— Run an external command in a new pane
Name-keyed actions — contributed at runtime by a provider or plugin, identified by a stable
string id (plugin.docker.restart). They have no WmAction variant — the enum is closed — but they
are otherwise ordinary actions: same catalog, same metadata, same policy, same dispatch.
Every action goes through one dispatch door, whatever surface triggered it:
Keyboard → KeyCombo → KeymapRegistry → ActionRef ─┐
Mouse/UI → Intent {action, args} ─────────────────┤
RPC → command ───────────────────────────────┤
▼
[interaction policy]
│
built-in ──► WmAction ──► native handler
name-keyed ─────────────► registered handler
A binding resolves to an ActionRef: a built-in is resolved when config loads (same cost and same
arg-parsing errors as before), while a name-keyed id is resolved at press time — because heca
reads your config before any provider has registered its actions.
This design means:
- Every action is traceable and hookable, and is judged by the same interaction policy
- Any action can be triggered by name — from a keybinding, a menu, a plugin, or RPC
- Arguments take one path: a menu item, an RPC call and a config binding construct the identical action
- Important capabilities should not be trapped behind one surface: when meaningful, the same action should be reachable from mouse/UI, keybindings, and RPC
There are two ways to add an action, and which one you want depends on where the code lives.
- A built-in action — you're editing heca itself. It gets a
WmActionvariant and a native handler. This is the path for a new WM capability. - A name-keyed action — a provider or (later) a WASM plugin contributes an action at runtime.
WmActionis a closed enum, so it cannot get a variant; it is identified by a stable string id instead. This is the path for anything that isn't core heca.
Both end up equal citizens: same catalog, same metadata, same interaction policy, same single dispatch door — so a menu item, a keybinding and an RPC call all reach either one identically.
Say you want my_custom_action.
1. Add the variant — heca/src/input/action.rs:
pub enum WmAction {
// ... existing variants
MyCustomAction,
}2. Give it a name. A unit action goes in action_from_name(); one that takes arguments goes in
build_action() instead (that's what parses a binding's args, an Intent's args, and RPC args —
one constructor for all three):
// unit:
"my_custom_action" => Some(WmAction::MyCustomAction),
// or parameterized, in build_action():
"my_custom_action" => Some(WmAction::MyCustomAction { amount: get_usize(args, "amount")? }),3. Classify it — action_policy() in heca/src/app/interaction.rs. This match is exhaustive:
if you skip this step, it will not compile. That is deliberate — it forces you to answer "should
this run while a floating pane owns the focus domain?"
WmAction::MyCustomAction => ActionPolicy::TiledOnly,
⚠️ AlwaysAllowedis a misnomer — the router blocks it when a floating pane is active.Globalis the only policy that is truly always allowed. See Interaction Policy below.
4. Write the handler — in the file under heca/src/handlers/ for what it acts on:
pub fn handle_my_custom_action(state: &mut AppState, _action: &WmAction) {
// Your logic here — only the action. The dispatcher asks for the frame, re-syncs focus and
// refreshes what lists the session afterwards, for every action.
}5. Register the handler in build_registry() — heca/src/app/registry/actions/mod.rs:
registry.register(WmActionKind::MyCustomAction, handle_my_custom_action);6. Describe it in the file for its category under heca/src/actions/builtins/ (pane.rs, layout.rs, …) — the file is its category. This is what gives the action its
label, icon and category everywhere it is shown (context menu, tooltip, command palette), and what
says which arguments it takes:
ActionDescriptor {
name: "my_custom_action",
label: "My Custom Action",
description: "What it does, in one line.",
icon: Some(Glyph::Gear), // any Glyph; None if it has no icon yet
args: &[], // takes none — see below if it does
},args has no default: a struct literal has to fill every field, so you cannot add an action without
answering the question. Empty means it takes none, and every argument handed to it is then an
argument nobody declared — which is reported, not ignored.
An action that reads arguments in build_action() declares each one here, in the same order:
args: &[
ArgDescriptor::required("pane_id", ArgKind::Int, "The pane to act on."),
ArgDescriptor::optional("focus_after", ArgKind::Bool, "Focus it afterwards. Default false."),
// A vocabulary argument points at the list beside its own parser, never a copy of it:
ArgDescriptor::required_enum("step", <FontZoomStep as EnumArg>::VALUES, "Which way to step."),
],This is what makes a mistake audible. heca compares every call against this list — a config.toml
binding when config is read, an Intent when it is dispatched — and names what is wrong:
[heca] binding 'my_custom_action': unknown argument 'pane_di' — did you mean 'pane_id'?
[heca] binding 'my_custom_action': missing required argument 'pane_id'
[heca] binding 'my_custom_action' cannot be built and will do nothing when pressed
A missing required argument stops the action (nothing can build it). An unknown or malformed one costs only itself: the rest of the call still stands. Three tests keep the declaration and the arm that reads it in step, so a list that stops matching the code fails the build rather than misleading someone later.
An action that requires an argument gets no default keybinding, and cannot be reached from its bare name at all. A key supplies no pane id, so a name that needs one would have to guess — which heca used to do, answering
delete_workspacewith workspace 0. Bind such an action with an explicitargstable (see[keys.mode]) or dispatch it from a menu, a plugin or RPC.
7. Bind it by default in keybindings.default.toml — not in Rust. The embedded default
files are the single source of truth for defaults; a default that lives only in code is invisible to
users:
[keys]
my_custom_action = "prefix+y"8. RPC parity is automatic. The RPC looks every name up in the same catalog (step 6), so
my_custom_action — with its arguments positionally in declared order, or as key=value — works over
RPC the moment it is described. There is nothing to add in heca/src/rpc.rs.
9. Optional — make it confirm first. Destructive actions declare a ConfirmSpec, and every
surface that triggers them confirms identically, because the guard lives on the action, not the
call site. Users toggle it in the [confirm] table.
Registered at runtime, identified by a stable string id. Because WmAction is closed, this is the
only way for code outside core heca to contribute an action.
Metadata and handler are registered together but stored apart, for a borrow reason rather than a
design one: a handler is fn(&mut AppState, …) and never receives the registry, so metadata must
be reachable from AppState (the ActionCatalog) while the handler table must be borrowable
alongside &mut AppState (the ActionRegistry).
use crate::actions::{register_dynamic, unregister_dynamic, ActionCategory, ActionMeta};
use crate::app::interaction::ActionPolicy;
use crate::chrome::PropValue;
use heca_grid_ui::Glyph;
use std::rc::Rc;
// At mount:
let handle = register_dynamic(
registry, // &mut ActionRegistry
catalog, // &mut ActionCatalog
ActionMeta {
name: "plugin.docker.restart".into(), // the id bindings / menus / RPC name
label: "Restart Container".into(),
description: "Restart the selected Docker container.".into(),
category: ActionCategory::System,
default_binding: String::new(),
icon: Some(Glyph::Play),
policy: ActionPolicy::Global, // REQUIRED — no permissive default exists
},
// Receives the Intent, so its args arrive as plain data — no closure ever crosses a plugin
// boundary. `&mut AppState` is the sanctioned write path for an action.
Some(Rc::new(|state, intent| {
let Some(PropValue::Text(container)) = intent.args.get("container") else {
return;
};
restart_container(state, container);
// No redraw here: the dispatcher asks for one after every action.
})),
);
// At unmount — retires the handler AND the metadata:
unregister_dynamic(registry, catalog, &handle.0);policy is a required field with no default. A built-in is forced to classify itself by an
exhaustive match; a name-keyed action has no variant, so nothing else would force the question — and a
default would mean "the author forgot" silently resolves to the most permissive setting in the system.
Once registered, the id behaves like any other action. Bind it:
[keys]
"prefix+d" = "plugin.docker.restart"
# With arguments — use a mode binding (flat bindings take no args):
[[keys.mode.bindings]]
action = "plugin.docker.restart"
keys = "w"
args = { container = "web" }A binding to an action that doesn't exist yet is not an error. heca reads your config before any provider or plugin has registered, so a dynamic id simply cannot resolve at load; it resolves when the key is actually pressed. The flip side is that a typo in an action name is not an error either — it just never fires. If a binding seems dead, check the name.
A typo in an argument name is different: heca knows what each built-in action takes, so it says so at startup rather than leaving you to work it out from a key that does nothing.
[heca] binding 'delete_workspace': unknown argument 'ws_idxx' — did you mean 'ws_idx'?
[heca] binding 'delete_workspace': missing required argument 'ws_idx'
[heca] binding 'delete_workspace' cannot be built and will do nothing when pressed
The describe-action <name> introspection command lists an action's arguments, their types and which
are required — including the ones a provider or plugin contributed. See
docs/widgets.md → Discovering actions at runtime.
Running one over RPC — action <name> [key=value …]. Every named built-in keeps its own RPC
spelling (focus-left, resize target=column amount=-50), but a component's or plugin's
declared action has no such spelling and never can: it is not a variant of any enum. The generic
verb takes any action id instead, so the whole catalog is reachable:
action workspaces.cursor_down # a component's own verb
action close_pane_by_id pane_id=7 # a built-in, by id, with arguments
Arguments are judged against the action's declaration, so a misspelled one says so rather than
silently defaulting. A call is policy-routed exactly like a keypress — a tiled_only action is
refused while a floating pane owns the screen, and a component's cursor verb is refused unless that
dock holds the keyboard. And an action whose component is not mounted is reported as such rather
than answered with success.
Passing None instead of a handler registers a declarative action: it has an id, metadata and a
policy, and it appears in menus and introspection, but the host cannot run it — it is forwarded to its
owner across the plugin boundary (this is how WASM plugin actions will work).
For how a widget or a plugin's UI fires one of these, see
docs/widgets.md → The other half of an Intent.
Not every action is allowed in every context. heca tracks a per-workspace focus domain — Tiled (the scrolling column layout) or Floating (a detached floating pane is active) — and an interaction policy layer decides whether each action is allowed before it runs.
When a floating pane is active, most actions are blocked so the floating pane stays put and the tiled layout isn't disturbed. These still work while floating:
- Pane-local actions —
float(toggle back to tiled),close,rename, and text selection. - Global app actions —
reload_config(hot-reload always works, even with a floating pane open).
Blocked while floating: focus/split/resize/swap/move, sidebar navigation, workspace switching, command palette, spawn, and pane select/swap overlays. The only ways to leave the floating domain are prefix+f (toggle float) or closing the floating pane.
An overlay that covers the panes blocks the same things. Whether an action may run is judged
against what owns the screen: a pane (Tiled), a floating pane (Floating), a dock holding the
keyboard (Container), or something covering the tiled area (Overlay) — a confirm dialog, or a
plugin panel that declared it obscures the panes. In that last one only truly global actions
(reload_config) get through, so nothing splits, zooms or closes a pane you cannot see. A focused
dock changes nothing about the app's own keys: prefix+Enter still splits the pane you last worked
in — what it adds is that a component's own cursor verbs (r, x, …) are reachable only while its
dock is being driven, from a key, the command palette or RPC alike.
The action flow:
Keyboard → KeyCombo → WmAction → dispatch_action() ──[policy]──► registry.execute() → handler
└─ blocked → no-op
This policy layer is why prefix+Shift+r (reload config) hot-applies appearance/keymap/theme changes without a restart — reload_config is a global action that stays reachable even when a floating pane is active. (This was a real bug: reload used to be silently blocked while floating, so style only applied on a full restart.)
For the full policy table (all six policy categories) and how to classify a new action, see AGENTS.md → "Interaction Policy".
See keybindings.default.toml for a complete reference of all default keybindings that can be customized.
Edit ~/.config/heca/config.toml:
[keys]
# Change prefix key
prefix = "ctrl+a"
# Rebind actions
focus_left = "prefix+h"
focus_right = "prefix+l"
zoom_column = "prefix+z"
# Multiple bindings for same action
focus_left = ["prefix+h", "prefix+ArrowLeft"]
# Global bindings (no prefix needed)
# Checked before forwarding to terminal
Alt+Enter = "spawn_terminal"For richer bindings with arguments, use [[keys.bind]]:
[[keys.bind]]
keys = "prefix+Ctrl+g"
action = "spawn_command"
args = { command = "lazygit", float = "true" }
[[keys.bind]]
keys = "prefix+z"
action = "zoom_column"One generic, cross-widget vocabulary drives every interactive widget/overlay — the context
menu, command palette, Select lists, Tabs, and Dialog focus. These apply only while such a
widget/overlay is focused (they never affect normal-mode input). They are split by axis:
[keys.widgets]
# Horizontal (left/right) — Tabs, a dialog's button row.
item_previous = ["ArrowLeft", "Ctrl+h"] # vim Ctrl+h
item_next = ["ArrowRight", "Ctrl+l"] # vim Ctrl+l
# Vertical (up/down) — menus, Select lists, the command palette.
menu_up = ["ArrowUp", "Ctrl+k"] # vim Ctrl+k
menu_down = ["ArrowDown", "Ctrl+j"] # vim Ctrl+j
# Shared.
activate = "Enter" # Commit / submit the selected entry or primary action
dismiss = "Escape" # Close / cancel the overlay
# Text-input editing shortcuts (plain typing / Backspace / arrows / word-motion are built in).
edit_delete_back = "Ctrl+h" # Delete one char before the caret
edit_delete_to_line_start = "Ctrl+u" # Delete from the caret to line start
edit_select_all = ["Ctrl+a", "Super+a"] # Select the whole field (Cmd+a on macOS)Notes:
- Tab / Shift+Tab are the universal focus-traversal primitive (handled by the focus system, and trapped inside a modal dialog) — always on, not listed here.
- A focused text field keeps its own keys first (field-first):
←moves the caret inside anInputbut navigates when a button is focused. The sharedCtrl+hdisambiguates by focus — it deletes in anInputbut isitem_previouson aTabs/dialog. - Menu/palette entries with a quick-pick letter can also be run by typing that letter; in the command palette, all other keys type into its filter field.
To remove a default keybinding, add it to [keys.unbind]:
[keys.unbind]
"prefix+f" = true # Disable float toggle
"prefix+q" = true # Disable pane select
"prefix+Shift+q" = true # Disable swap paneWhy unbind?
- Free up keys for custom bindings
- Disable features you don't use
- Resolve conflicts with custom bindings
The action still exists — you can rebind it to a different key in [keys]. For example, if you unbind prefix+f (float), you can rebind it to prefix+Shift+f.
By default, prefix+z toggles the active column between:
- its normal stored width
- a viewport-wide zoomed width
Each column tracks its own zoom state, so you can zoom multiple columns independently.
Press prefix+z again on the active column to restore that column's previous width.
Modes are groups of bindings that stay active until Escape or Enter is pressed. The default resize mode is an example:
# Default built-in: prefix+r enters resize mode
# In resize mode the key points the way the DIVIDER travels, whichever
# column or pane is active. The TARGET decides the axis — a column is
# resized across, a pane down — so only `amount` and (optionally) `edge`
# are needed. Positive is right / down:
# h / l → move the column's right edge left / right (narrower / wider)
# j / k → move the pane's divider down / up
# Arrow keys work too
# Escape / Enter → exit mode
#
# A pane's divider is the one below it, or the one above when it is the LAST
# pane — so on the last pane `j` moves that divider down and the pane gets
# shorter. `prefix+Shift+=` / `prefix+Shift+-` are the size verbs instead:
# they grow / shrink the active pane whichever edge has to move.
#
# CHOOSING THE EDGE. A pane has two horizontal edges, and `edge` says which one
# moves. Omit it and you get the one above — exactly as before. `edge = "top"`
# takes the pane's upper edge instead, so the defaults are:
# Shift+j / Shift+k → move the pane's TOP edge down / up
# Positive is still down the screen, so Shift+j shrinks the pane from above.
# Nothing happens on the first pane, which has no edge above it.
#
# [[keys.mode.bindings]]
# action = "resize"
# keys = "Shift+k"
# args = { target = "pane", amount = "-40", edge = "top" }
#
# The same holds on the x axis. A column's LEFT edge is where the column
# before it ends, so `edge = "left"` moves that boundary:
# Shift+h / Shift+l → move the column's LEFT edge left / right
# Positive is still right, so Shift+l shrinks the column from the left.
# Nothing happens on the first column, which has nothing to its left.
#
# `edge` accepts "auto" (the default), "top", "bottom", "left" and "right".
#
# MOVING WHILE STILL RESIZING. The mode is sticky — it runs an action and stays
# until Escape or Enter — so a focus action bound inside it moves the focus
# without leaving:
# Ctrl+h/j/k/l (and Ctrl+arrows) → focus another column / pane, still resizing
# Nothing special is needed for this: any action can be bound inside a mode, and
# a sticky mode keeps running.
# Custom modes
[[keys.mode]]
name = "my_mode"
trigger = "prefix+o" # Key that enters the mode; "" = no key of its own
sticky = true # true = stay until Esc (default); false = one key, then exit
[[keys.mode.bindings]]
action = "focus_left"
keys = "h"
[[keys.mode.bindings]]
action = "focus_right"
keys = "l"The built-in modes (resize, sidebar, selection) are merged back in by name, so
you can add a key without redeclaring the whole mode — but the fields are not merged
the same way:
| field | when you redefine a built-in mode |
|---|---|
bindings |
added to the built-in ones — you keep every default key |
trigger, sticky |
replace the built-in values |
So repeat trigger and sticky exactly as the default file has them. Omitting
trigger sets it to "", which for a mode like resize silently means prefix+r
stops entering it.
# Add `p` as a second "previous search match" key, keeping every other selection key.
[[keys.mode]]
name = "selection"
trigger = "" # as in keybindings.default.toml — omitting it would clear the trigger
sticky = true # as in keybindings.default.toml — omitting it would reset stickiness
[[keys.mode.bindings]]
action = "search_prev_match"
keys = "p"An empty trigger means the mode has no key of its own — something else in heca
puts you there. selection works this way: you enter it with the
enter_selection_mode action (prefix+s) or by scrolling up at a prompt.
These blocks work in config.toml as well as keybindings.toml: both files are
merged into one configuration before it is read.
Selection mode is a built-in sticky mode entered with prefix+s, or
automatically by scrolling up with the wheel at a non-grabbed prompt.
It provides tmux copy-mode-like navigation keys: h/j/k/l for cursor
movement, y to copy, u/d for half-page scroll, Ctrl+u/Ctrl+d for
full-page scroll, g/Shift+g for top/bottom, and Esc to exit (snap to
bottom + clear selection). The trigger field is ignored because Selection mode
is entered via actions, not a keybinding trigger.
Sticky vs Non-sticky modes:
- Sticky (
sticky = true): Stay in mode untilEscapeorEnter. Resize mode is sticky. unbind = ["…"]: keys to take out of the mode, the shipped ones included — a block's own bindings are added to the shipped ones, so this is how a key is given up. It is how the way out of a dock or an overlay is moved (see thefocusandlayerblocks): bind the action to another key, then unbindEscape.- Non-sticky (
sticky = false, or chord): Execute one action then exit. Likeprefix+w→1creates workspace 1.
heca checks bindings in this order:
- Mode bindings — If in a custom mode, check mode-specific keymap
- Prefix mode — If in prefix mode, check "normal" keymap for
prefix+keycombos - Global bindings — In Normal mode, check "global" keymap for direct
Alt+key/Super+keycombos - Forward to terminal — If no binding matched, send key to focused pane's backend
heca can launch PTY-backed command panes using [[keys.command]]:
[[keys.command]]
keys = "prefix+g"
command = "lazygit"
float = true
close_pane = true
[[keys.command]]
keys = "prefix+t"
command = "btm" # bottom system monitor
close_pane = true
keep_on_error = true
[[keys.command]]
keys = "Alt+Enter"
command = "alacritty"Supported options today:
kind = "terminal"(default).appandpluginare reserved and currently report "not yet implemented".float = trueto open as a centered floating pane.close_pane = trueto close the pane when the command exits.keep_on_error = trueorkeep_on_success = trueto overrideclose_panefor that exit outcome.
Commands run in a real PTY using the user's shell (-ic on Unix, /C on Windows), so existing shell-style command strings keep working while preserving interactive shell behavior.
You can override how foreground programs are presented in pane chrome through a
canonical [program.<id>] entry with optional raw-process aliases:
[program.nvim]
name = "Neovim"
processes = ["v", "nvim", "nv"]
icon = "file_code"
description = "modal editor"
color = "#89b4fa"Rules:
idis your canonical app entry name; it does not need to match the detected process exactly.processeslists raw foreground process names that should resolve to that entry.- All fields are optional. Partial overrides are merged over built-in defaults.
- Built-in defaults are seeded in code for common shells and a small well-known catalog:
nvim,vim,helix,yazi,ranger,claude,codex,opencode,pi. - Unknown programs fall back to their raw name and the same terminal icon used by pane cards today.
iconis a semantic Phosphor icon name. Canonical spellings are snake_case such asterminal,file_code,folder,folder_open,git_branch,gear, andsearch.kebab-casealiases also parse.[programs.<id>]still parses for backward compatibility.disabled = trueremoves a built-in entry cleanly and restores raw-name + terminal-icon fallback.coloraccepts#rrggbbor#rrggbbaa.
Example override / removal:
[program.nvim]
name = "Neovim"
processes = ["v", "nvim"]
icon = "file_code"
color = "#89b4fa"
[program.ranger]
disabled = true
⚠️ spawn_paneDOES NOT EXIST. Nothing below this line is implemented — not the action, notfloat_active_at, not theprogram/argv/width/heightarguments, and not thebrowserornvim_guipane kinds. A binding to an unknown action is not an error: it simply never fires, so copying one of these produces a key that silently does nothing.What exists today is
spawn_command(command,kind,float,close_pane,keep_on_error,keep_on_success) and[[keys.command]]— see Keybindings that carry arguments. This section is kept as the agreed design for the sized, multi-kind spawn.
The agreed future-ready action model is spawn_pane, designed for multiple pane kinds:
terminalbrowsernvim_gui- temporary/mock fallback while a backend is not implemented
Examples:
# Terminal-like pane, tiled
[[keys.bind]]
keys = "prefix+g"
action = "spawn_pane"
args = { kind = "terminal", program = "lazygit", argv = [] }
# Terminal-like pane, floating, centered, 800x400 with implicit px
[[keys.bind]]
keys = "prefix+Shift+t"
action = "spawn_pane"
args = { kind = "terminal", program = "btm", argv = [], float = true, width = "800", height = "400" }
# Terminal-like pane, floating, centered, 800px x 400px with explicit px suffix
[[keys.bind]]
keys = "prefix+Shift+y"
action = "spawn_pane"
args = { kind = "terminal", program = "htop", argv = [], float = true, width = "800px", height = "400px" }
# Terminal-like pane, floating, centered, 80% of content area
[[keys.bind]]
keys = "prefix+Shift+v"
action = "spawn_pane"
args = { kind = "terminal", program = "nvim", argv = ["."], float = true, width = "80%", height = "80%" }
# Future browser pane
[[keys.bind]]
keys = "prefix+Shift+b"
action = "spawn_pane"
args = { kind = "browser", float = true, width = "1200", height = "800" }
# Future nvim GUI pane
[[keys.bind]]
keys = "prefix+Shift+n"
action = "spawn_pane"
args = { kind = "nvim_gui", float = true, width = "1000", height = "700" }
# Float the active pane using explicit geometry
[[keys.bind]]
keys = "prefix+Shift+f"
action = "float_active_at"
args = { width = "95%", height = "95%" }
# Zoom toggle on a normal binding
[[keys.bind]]
keys = "prefix+z"
action = "zoom_column"
# Same spawn ideas inside a mode
[[keys.mode]]
name = "spawn"
trigger = "prefix+s"
sticky = true
[[keys.mode.bindings]]
action = "spawn_pane"
keys = "g"
args = { kind = "terminal", program = "lazygit", argv = [] }
[[keys.mode.bindings]]
action = "spawn_pane"
keys = "n"
args = { kind = "terminal", program = "nvim", argv = ["."], float = true, width = "80%", height = "80%" }
[[keys.mode.bindings]]
action = "zoom_column"
keys = "z"The agreed behavior for float toggling is:
prefix+fremains the float/unfloat toggle- if a pane was originally tiled, unfloat restores it to its original tiled position
- if a pane was spawned directly as floating with no original tiled slot, unfloat should place it into a new column
prefix+zis reserved for column zoom toggle
For tuning the render path, run heca with HECA_FRAME_TIMES set (any value):
HECA_FRAME_TIMES=1 cargo run --release -p hecaEvery 120 frames that drew, it prints one line on stderr — the average time render_frame took on the
CPU and the average number of render passes it began:
[frame-times] 120 frames: 1.84 ms CPU, 9.0 render passes per frame (GPU time is not measured)
It is CPU time only: the GPU runs after the frame is submitted, so work moved from the CPU to the GPU looks like a win here. Off unless the variable is set.
┌───────────────────────────────────────────────────────┐
│ heca (main binary) │
│ ├── winit event loop │
│ ├── input routing (prefix/keymap) │
│ ├── ActionRegistry dispatch │
│ ├── render() — GPU compositor │
│ └── current chrome + future pluggable chrome host │
├───────────────────────────────────────────────────────┤
│ heca-renderer │
│ ├── PrimitiveRenderer (rects, borders) │
│ └── TextRenderer (cosmic-text atlas) │
├───────────────────────────────────────────────────────┤
│ heca-core │
│ ├── layout/ │
│ │ ├── Session → Workspace → Column → Pane │
│ │ ├── ViewOffset (animated scroll) │
│ │ └── Animation (easing, springs) │
│ └── backend/ │
│ ├── PaneBackend trait │
│ ├── TerminalBackend (PTY + vte) │
│ └── FakeBackend (layout testing) │
├───────────────────────────────────────────────────────┤
│ heca-config │
│ └── TOML config + theme loading │
├───────────────────────────────────────────────────────┤
│ future direction │
│ ├── chrome host with left/right/top/bottom regions │
│ ├── built-in containers (first: WorkspacesContainer) │
│ └── dynamic actions + later WASM/plugin containers │
└───────────────────────────────────────────────────────┘
Key design decisions:
- NIRI layout engine: Horizontal scrolling columns, not BSP trees. Column widths are independent.
- Separation of concerns: Layout in
heca-core, GPU inheca-renderer, orchestration inheca. - ActionRegistry: All WM commands go through one dispatch point. Today that is centered on
WmAction; the long-term direction is toward dynamic/string-based actions for pluggable chrome containers too. - KeymapRegistry: Mode-specific keymaps. Modes are groups of bindings active until Esc/Enter.
- Prefix mode: Intentionally tmux-style to avoid conflicts with hosted applications.
- PaneBackend trait: All content sources (terminal, Neovim, browser) implement the same interface.
- Sidebar shell vs container: a widget is a component, placeable anywhere. A container (dock) holds widgets. A sidebar is a shell that hosts containers and knows almost nothing about them — left and right are only positions, and the same container behaves identically in either. What differs between the two sidebars is their content, not what they are. Placing a container is placing it: a second placement is one call (
WorkspacesContainerProvider::placed(id, region)), not a second implementation, and the content comes from the shared store while state that is per placement — its scroll position — is keyed by mount id. - Containers share a region by fraction, never by fixed size: a container declares its share of the region as a flex grow factor, defaulting to an equal share. One container takes the whole region, two take half each,
2.0beside1.0takes two thirds, and0.0means content-sized. It is the region that applies the share, because a share only means something relative to siblings a container cannot see. A fraction survives a window resize; a pixel height does not. Seedocs/widgets.md. - Scrolling is composed, not owned: a scroll area is a container, so it is nested wherever content should scroll — including inside another container. Two containers in one sidebar scroll independently because each nests its own; the shell does not wrap them in a viewport, because a viewport measures content at its natural height and would leave the containers with no height to divide. Nesting decides which one acts: the innermost that can move on that axis, for the wheel and the keyboard alike. See
docs/widgets.md. - Action reachability: important actions should be reachable from mouse/UI, keybindings, and RPC when meaningful on those surfaces.
- Surface compositor (layering): on-screen surfaces (background, panes, sidebar, floating panes, overlays/modals, future exposé) form a tree whose position defines z-order — no hardcoded levels. One uniform rule (context activation + geometric occlusion) decides what is interactive, starting with the universal KeyHint picker (
prefix+/). See the planner (F003/P019) — see the planner (F003/P019) — the contract for adding any new layer, surface, overlay, or button.
Not yet available. This describes the planned plugin model (target Phase 9) so early adopters can see where it's going. The SDK below does not exist yet. Full guide with detailed examples: docs/plugins.md.
heca will be extensible via WASM plugins. A plugin observes app state, dispatches
intents (never mutating state directly), and contributes UI as a declarative
ViewNode tree — a recursive widget tree like Flutter's Widget or SwiftUI's
View, where a container node holds a vector of child widgets. The host maps that
tree to real heca-grid-ui widgets, themes them, and owns focus/clipping/overlays.
// A container is just a ViewNode with children — compose arbitrarily:
Panel::new().title("Hello")
.child(VStack::new().gap(6).padding(10)
.child(Label::new(format!("Active pane: {name}")))
.child(Button::new("Refresh")
.variant(Variant::Accent) // semantic, themed by the host
.on_press(intent("example.hello.refresh", {})))) // intent, not a callbackComplex widgets (tables, forms) and rich modals (a modal body is itself a
ViewNode) are built the same way. Context menus and KeyHints come from the host, not
from nested widgets: a context menu is a host-owned dropdown the plugin requests (or
declares with .on_context), and any plugin widget with an on_press intent is
automatically leader-hintable. See the full guide for panel / table / modal-with-form
examples and the "Context menus & KeyHint" section:
docs/plugins.md.
| Phase | Status | Description |
|---|---|---|
| 1 — The Shell | ✅ Complete | GPU window, text rendering, theme system |
| 2 — The Workspace | ✅ Complete | NIRI layout, animations, input, sidebar |
| 3 — The Content | 🔄 In Progress | Terminal backend, Neovim msgpack-RPC |
| 4 — The Platform | 📋 Planned | Session persistence, JSON-RPC/RPC, pluggable chrome, dynamic actions, WASM/plugin containers |
See .planning/ROADMAP.md for detailed requirements.
MIT License — see LICENSE for details.
- NIRI by Ivan Molodetskikh — The scrollable-tiling compositor that inspired heca's layout engine.
- cosmic-text by System76 — GPU text rendering with excellent font shaping.
- wgpu — Safe, portable GPU API for Rust.