17 KiB
pmacs keybindings — reference
Last verified against main @ f8096ff (2026-07-20). This is a
snapshot, not generated output — when a PR adds, removes, or rebinds a
key, update this file in the same PR (see §6). If you're an agent and
this file looks stale against the code it cites, trust the code.
pmacs keys come from two independent places:
- The Lua keymap (§1–2) —
pmacs.keymap.bind{...}calls, resolved by the Rust dispatcher against whateverinit.luahas bound at runtime. Fully user-rebindable: unbind or rebind any of these from init.lua (§5). - Rust-hardcoded modal shadows (§3) — isearch, query-replace,
the minibuffer/prompt, the completion popup, and the context menu
each shadow the Lua keymap while active:
EditorInstance::dispatch_key(src/editor.rs:658-733) checks these modes, highest-priority first, before a key ever reaches the Lua dispatcher. Not user-configurable — there is nopmacs.keymapsurface for them; changing one means editing the mode'sfrom_chorddecoder in Rust.
Notation matches what pmacs.keymap.bind accepts: C- = Ctrl, M- =
Alt/Meta, S- = Shift, bare letters/punctuation self-insert when
unmodified. Named keys are angle-bracketed (<left>, <up>, <home>)
or all-caps (RET, BS/Backspace, DEL/Delete, TAB, SPC).
Sequences separated by spaces (C-x C-s) are chords typed in order.
1. Global keymap
Source: builtin/keymaps/default.lua unless noted. All bound at
scope = "global".
Cursor motion
| Key | Command |
|---|---|
C-a / <home> |
cursor.line-start |
C-e / <end> |
cursor.line-end |
C-f / <right> |
cursor.right |
C-b / <left> |
cursor.left |
C-n / <down> |
cursor.down |
C-p / <up> |
cursor.up |
C-<left> / M-b |
cursor.word-left |
C-<right> / M-f |
cursor.word-right |
C-<up> / M-{ |
cursor.paragraph-up |
C-<down> / M-} |
cursor.paragraph-down |
<pageup> / M-v |
cursor.page-up |
<pagedown> / C-v |
cursor.page-down |
M-g g / M-g M-g |
cursor.goto-line (builtin/runtime/editops.lua) |
Selection (CUA shift-select)
Plain motion preserves an existing selection instead of dropping it (Emacs-flavored default, not strict CUA).
| Key | Command |
|---|---|
S-<left> / S-<right> |
cursor.select-left / cursor.select-right |
S-<up> / S-<down> |
cursor.select-up / cursor.select-down |
S-<home> / S-<end> |
cursor.select-line-start / cursor.select-line-end |
C-S-<left> / C-S-<right> |
cursor.select-word-left / cursor.select-word-right |
C-S-<up> / C-S-<down> |
cursor.select-paragraph-up / cursor.select-paragraph-down |
Editing
| Key | Command |
|---|---|
BS |
buffer.delete-backward |
DEL / C-d |
buffer.delete-forward |
RET |
edit.newline-and-indent |
TAB |
buffer.tab |
C-BS / C-h |
buffer.delete-word-backward (see §4 for the C-h rationale) |
M-BS |
buffer.delete-word-backward |
C-DEL |
buffer.delete-word-forward |
M-d |
buffer.delete-word-forward |
M-u |
edit.upcase (editops.lua) |
M-l |
edit.downcase (editops.lua) |
M-c |
edit.capitalize (editops.lua) |
C-t |
edit.transpose-chars (editops.lua) |
M-t |
edit.transpose-words (editops.lua) |
M-z |
edit.zap-to-char (editops.lua) |
M-<up> / M-<down> |
edit.move-line-up / edit.move-line-down (editops.lua) |
M-^ |
edit.join-line (editops.lua) |
M-; |
edit.toggle-comment (builtin/runtime/comment.lua) |
M-d/M-BScurrently plain-delete the word — they are not kill-ring members yet (a named deferral; seedocs/agent-handoff.md§6, "word kills").edit.kill-line(below) is the only word/line kill wired into the ring so far.
Clipboard & kill ring
| Key | Command |
|---|---|
M-w |
edit.copy |
C-w |
edit.cut |
C-y |
edit.paste |
C-x h |
edit.select-all (Emacs mark-whole-buffer) |
C-k |
edit.kill-line (builtin/runtime/killring.lua) |
M-y |
edit.yank-pop — replace the just-yanked text with the previous kill, immediately after C-y (killring.lua) |
Undo / redo
Multiple bindings exist because terminals disagree on how Ctrl+/
encodes; see §4.
| Key | Command |
|---|---|
C-/ / C-_ / C-4 / C-x u |
buffer.undo |
C-? / C-S-_ / C-x r |
buffer.redo |
Search & replace
Once a search is running, C-s/C-r step to the next/previous match
and M-r toggles literal↔regex — those are Rust-hardcoded isearch
keys, not Lua bindings (§3).
| Key | Command |
|---|---|
C-s |
search.forward (starts isearch) |
C-r |
search.backward (starts isearch) |
C-M-s |
search.forward-regex |
C-M-r |
search.backward-regex |
M-% |
query-replace (starts an interactive replace session, §3) |
C-M-% |
query-replace-regexp |
Multi-key (C-x) chords
| Key | Command |
|---|---|
C-x C-s |
buffer.save |
C-x C-c |
editor.quit |
C-x 2 |
window.split-horizontal |
C-x 3 |
window.split-vertical |
C-x o / C-x O |
window.focus-next / window.focus-prev |
C-x 0 |
window.close |
C-x 1 |
window.close-others |
C-x b |
editor.switch-buffer |
C-x C-b |
editor.list-buffers (opens the *buffer-list* panel, §2) |
C-x <right> / C-x <left> |
editor.next-buffer / editor.previous-buffer |
C-x C-r |
recent-files (builtin/runtime/recentf.lua) |
Command palette & cancellation
| Key | Command |
|---|---|
M-x |
editor.execute-command — prompts (via the minibuffer, §3) for any command by name |
C-g |
editor.cancel — resets the dispatcher / clears an unfinished prefix |
Completion
| Key | Command |
|---|---|
C-M-i |
completion.at-point (builtin/runtime/completion.lua) — opens the popup; popup navigation is Rust-hardcoded (§3) |
LSP
Source: builtin/runtime/lsp.lua. M-. follows the cross-editor
go-to-definition convention; the rest sit on the C-c prefix to keep
printable letters free for self-insert.
| Key | Command |
|---|---|
M-. |
lsp.go-to-definition |
M-? |
lsp.find-references (opens *references* panel, §2) |
M-, |
lsp.jump-back (unwind the cross-file jump ring) |
C-c o |
lsp.document-symbols (opens *outline* panel, §2) |
C-c r |
lsp.rename |
C-c a |
lsp.code-actions |
C-c i |
lsp.inlay-hints |
C-c y |
lsp.semantic-tokens |
C-c h |
lsp.hover |
C-c H |
lsp.hover-doc (opens *lsp-help* panel, §2) |
C-c s |
lsp.signature-help |
C-c f |
lsp.format-buffer |
builtin/runtime/lsp.lua initially binds M-g n / M-g p to
diagnostic navigation. compile.lua loads afterward and deliberately
replaces them with the unified error dispatcher below.
Compile, shell command, and unified errors
Source: builtin/runtime/compile.lua.
| Key | Command |
|---|---|
M-g n / M-g p |
error.next / error.previous — compile/grep errors when that source has claimed navigation, otherwise LSP diagnostics |
C-x ` |
error.next |
M-! |
shell.command — asynchronous output in *shell-command* |
C-c c |
compile.run — prompts, prefilled from the detected project kind |
M-x help is the index of the discovery family, rendered as a
*help* buffer inside the editor, and is what the startup welcome
points at. The family — all reachable by name, none bound to a key:
| Command | Shows |
|---|---|
help.describe-command |
a command's description and bindings |
help.describe-setting |
a setting's type, default, effective value |
help.describe-key |
what a chord runs in this buffer |
help.describe-mode |
the active buffer's major mode |
help.describe-buffer |
the active buffer |
help.describe-hook |
a hook and its listeners |
help.where-is |
which keys run a command |
help.list-commands |
every command with its description |
help.list-keybindings |
every binding, grouped by scope |
help.list-settings |
every registered setting |
help.apropos |
substring search over names and descriptions |
editor.describe-command and editor.describe-setting still work as
deprecated aliases of their help.* counterparts. It is the root of
the eventual help family (help.keys and friends arrive with the
discovery arc), so it takes no keybinding yet — C-h is not free:
it deletes a word because non-kitty terminals cannot tell Ctrl+Backspace
from Ctrl+H.
compile.recompile is available through M-x, and through g inside
*compilation*; no global key is assigned to it. C-c c is unreachable
from inside a terminal window (C-c is consumed as the escape key) and
inside a repl buffer (which binds C-c at buffer scope); M-x compile.run still works in both.
2. Buffer-local panel keymaps
Read-only panel buffers built on pmacs.listview.open (buffer scope
{ scope = "buffer", buffer = <id> }; see builtin/runtime/listview.lua)
all share one keymap:
| Key | Action |
|---|---|
RET / SPC |
listview.visit — act on the item under the cursor |
n / <down> |
cursor.down |
p / <up> |
cursor.up |
TAB |
listview.toggle — collapse/expand the tree node under the cursor; a panel with no tree rows delegates to buffer.tab |
g |
listview.refresh — re-run the data source and re-render |
q |
listview.quit — restore the buffer that was active before the panel opened |
(TAB arrived with the tree primitive and this table had not recorded
it. Noted rather than quietly added: the omission predates the git lane
that found it.)
Panels currently built on this: *references*, *outline*,
*lsp-help* (hover docs), *lsp* (lsp.status), and *git-status*
(git.status). Header text always spells out the panel's own legend
inline.
A panel may add keys of its own through an optional keys table on the
open spec, bound through the same buffer-local path — so they are
inspectable by describe-key and rebindable from init.lua, exactly
like the fixed set. They are installed once with the panel's buffer and
may not collide with the fixed set, nor prefix it. One panel uses this
today:
| Buffer | Key | Command |
|---|---|---|
*git-status* (git.status) |
d |
git.diff-file — the diff for the file under the cursor, into *git-diff* |
git.status gets no global chord: an opening key is a
command-surface decision the Stage 1 framing did not make, so the entry
point is M-x git.status.
*buffer-list* (editor.list-buffers, C-x C-b) uses its own
keymap, layered on the same idiom, in builtin/commands/default.lua:
| Key | Command |
|---|---|
RET / SPC |
editor.buffer-list-visit |
n / <down> |
cursor.down |
p / <up> |
cursor.up |
d |
editor.buffer-list-mark-delete |
u |
editor.buffer-list-unmark |
x |
editor.buffer-list-execute — kill every marked buffer |
k |
editor.buffer-list-kill-now |
g |
editor.buffer-list-refresh |
q |
editor.buffer-list-quit |
One-off buffer-local bindings, each scoped to a single generated buffer:
| Buffer | Key | Command |
|---|---|---|
*workers* (editor.list-workers) |
C-c C-k |
workers.cancel-at-point (builtin/runtime/async.lua) |
*pmacs-instance* (editor.describe-instance-buffer) |
q |
buffer.kill-this (commands/default.lua) |
*help* (editor.describe-command) |
q |
buffer.kill-this |
REPL buffers (builtin/packages/repl/init.lua) |
RET |
pmacs.repl.submit-current |
| REPL buffers | C-c |
pmacs.repl.send-sigint-current |
| REPL buffers | C-d |
pmacs.repl.send-eof-current — closes stdin on an empty line, else deletes forward |
Compile-mode generated buffers (*compilation* and
*shell-command*) have their own buffer-local map:
| Key | Command |
|---|---|
RET |
compile.visit-error |
n / p |
compile.next-error-line / compile.previous-error-line |
q |
compile.quit |
C-c C-k |
compile.kill |
g |
compile.recompile (*compilation* only) |
| every shipped undo/redo chord | compile.undo-noop — generated output is intercept-read-only |
The REPL package (builtin/packages/repl/) is shipped but opt-in —
loaded via require, not part of the always-on builtin/runtime
lane. Its bindings only exist in a buffer created by a REPL session.
3. Rust-hardcoded modal keys
These live in src/editor.rs (and src/minibuffer.rs for the
prompt) as small from_chord(chord) -> Action decoders, one per mode,
checked in priority order by EditorInstance::dispatch_key
(src/editor.rs:658-733, highest first): context menu → isearch →
query-replace → minibuffer → completion popup → normal Lua dispatch.
Each decoder's rustdoc names its own key list; this table mirrors
those. They are not reachable through pmacs.keymap — there is
deliberately no Lua surface for them (keeps the set curated; see the
R51 rationale cited in lib.rs/lua_bindings/mod.rs).
Isearch (SearchKey, editor.rs:1858-1919) — active after
C-s/C-r/C-M-s/C-M-r:
| Key | Action |
|---|---|
C-s / <down> |
next match |
C-r / <up> |
previous match |
RET / C-m |
accept — keep cursor + highlights |
C-g / Esc |
cancel — restore the origin cursor |
BS / C-h |
shorten the query by one character |
M-r |
toggle literal ↔ regex |
| any printable char | extend the query |
Query-replace (QueryReplaceKey, editor.rs:1926-1961) — active
after M-%/C-M-%:
| Key | Action |
|---|---|
y / SPC |
replace this match, advance |
n / BS / Delete |
skip this match, advance |
! |
replace this and every remaining match, no more prompts |
. |
replace this match, then quit |
q / RET / Esc / C-g |
quit (replacements already made are kept) |
Minibuffer / prompt (MinibufferAction, minibuffer.rs:418-527)
— backs every pmacs.minibuffer.read call: M-x, query-replace's
from/to prompts, find-file, etc.:
| Key | Action |
|---|---|
RET / C-m |
accept |
C-g |
cancel |
TAB / C-i |
complete to the selected candidate |
<up> / <down> |
prev/next candidate if a dropdown is showing, else history navigation |
C-p / C-n |
history prev/next, unconditionally |
<left> / C-b, <right> / C-f |
cursor move |
<home> / C-a, <end> / C-e |
line start/end |
BS |
delete backward |
Delete / C-d |
delete forward |
M-n / M-p |
scroll the selected candidate forward/back |
| any other printable char | self-insert |
In-buffer completion popup (CompletionPopupKey,
editor.rs:2019-2059) — active after C-M-i or an LSP-triggered
popup. Unlike the others this is a partial shadow: only the keys
below are intercepted; everything else (typing, motion) falls through
to normal dispatch, so typing keeps self-inserting while the popup is
open.
| Key | Action |
|---|---|
<down> / C-n |
next candidate |
<up> / C-p |
previous candidate |
TAB / RET |
accept the highlighted candidate |
Esc / C-g |
dismiss |
Context menu (MenuKey, editor.rs:1966-2009) — opened by
right-click, not a keybinding itself, but shadows the keymap while
open:
| Key | Action |
|---|---|
<down> / C-n |
next item |
<up> / C-p |
previous item |
RET |
invoke the highlighted item |
Esc / C-g |
cancel |
| any other key | dismiss (click-away semantics) |
Frontend detach — F12 (any modifiers) detaches an attached
frontend from the daemon (src/attach.rs:997-1006, checked at
attach.rs:818). Not a UI mode inside the editor core, but another
literal-KeyCode interception outside the Lua keymap; tentative for
v0.1 per the comment there (chosen because F12 is rarely bound to
anything else).
4. Terminal-compatibility caveats
C-hdoubles asC-BS. Most terminals without the kitty keyboard protocol can't disambiguateCtrl+BackspacefromCtrl+H— both legacy paths send byte0x08.C-his bound tobuffer.delete-word-backwardalongsideC-BSso the shortcut works on legacy terminals. pmacs does not useC-has an Emacs-style help prefix; a user who wants that can rebind it.- Undo/redo have redundant bindings (
C-/,C-_,C-4for undo;C-?,C-S-_for redo) because terminals encodeCtrl+/several different ways. Kitty's keyboard protocol routes most cleanly throughC-/; the alternates keep legacy/remote terminals working. - Kitty-protocol-only chords (e.g. distinguishing
C-ifromTAB) degrade gracefully where noted above — check the frontend's terminal capability negotiation if a chord seems to not fire.
5. Changing bindings
pmacs.keymap.bind / pmacs.keymap.unbind are ordinary Lua API,
callable from init.lua:
pmacs.keymap.bind { scope = "global", sequence = "C-c g", command = "cursor.goto-line" }
pmacs.keymap.unbind { scope = "global", sequence = "M-z" }
scope = "buffer" additionally takes buffer = <id>; buffer-local
bindings are pruned automatically when that buffer is removed. This
covers §1 and §2 only — §3's Rust-hardcoded modal keys have no Lua
surface (see §3's intro).
6. Keeping this file honest
Update this file in the same PR whenever a binding is added, removed,
or moved — same discipline as docs/agent-handoff.md. To re-derive it
from scratch instead of trusting the table: grep builtin/ for
pmacs.keymap.bind/.bind( and pmacs.listview.open, and grep
src/editor.rs / src/minibuffer.rs for from_chord.