Command reference

Every command the runtime registers, and every key it binds. Both are tables you can copy from: a keystroke here is the string you would put in keymap.add, and a command name is the string you would put in command.perform.

What is not in here is as important as what is. Every workflow is a plugin in cdin-x, and every plugin owns its own keystrokes. Only three extensions are in a build — vim, the manager, the default theme — so on a fresh build the four strokes below do nothing at all:

stroke extension that owns it
Ctrl+Shift+P palette — run any command by name
Ctrl+P finder — fuzzy-match the project’s files
Ctrl+O finder — open a path
Ctrl+Shift+O finder — open a folder

That is the intent rather than an omission: an editor with no extensions should have no dead keys, and an editor with them should get those keys from the plugin that actually implements them. Install them from the manager and they appear with no restart. Extensions lists every optional extension and the keys it adds; cdin-x’s plugin index has one page each.

Two strokes are bound here despite belonging to that half, because they are the runtime’s own: Ctrl+Shift+M for the manager and Ctrl+Shift+L for the log.

The model

A command is a name, a predicate, and a function.

command.add(predicate, map, overwrite)
command.remove(names)          -- a name, or a list of names

predicate decides whether the command is available right now, which is different from whether it is registered. It has three legal forms:

form available when
nil always
a string — a module path that module’s returned function says so; "core.views.docview" means while a document view is active
a class table core.active_view is an instance of it

overwrite permits re-registering a name that already exists. Without it, command.add asserts — which is deliberate: two plugins claiming one name is a bug, and the alternative is a binding that silently runs the wrong thing.

Availability, not registration, is what a keystroke sees. command.perform returns false when the predicate fails and does nothing. That is why the runtime can route Enter to command:submit and have it fall through to doc:newline when no prompt is open, instead of erroring.

Keystrokes

Keys are written the way the editor spells them: lowercase, modifiers in ctrl+alt+shift order joined by +, arrows as left/right/up/down, and keypad enter distinct from return.

A value may be a list, which is a fallback chain: the commands are tried in order and the first whose predicate holds runs. That is how Ctrl+D can belong to find-and-replace while a match is selected and to the document otherwise, without either knowing the other exists.

Document

Available while a document view is active. The keys below are the default ones; several of these are also how vim mode reaches them, which is the point — the motions are ordinary commands, so a keymap or a vim integration is all it takes to re-bind them.

Saving and files

key command does
Ctrl+S doc:save save; prompts for a path on an unnamed document
Ctrl+Shift+S doc:save-as save under a name you type
doc:rename save under a new name and delete the old file
doc:toggle-line-ending flip this document between LF and CRLF

doc:save delegates to doc:save-as when there is no filename yet, so the “you have to name it first” path is the same prompt either way.

doc:rename writes the new file, then removes the old one. It repoints any document already open on the old path. There is no copy-then-delete command in the runtime — copy and move are filesystem workflows, and the ones in cdin-x reach for the shell.

Editing

key command does
Ctrl+Z doc:undo
Ctrl+Y doc:redo
Ctrl+X doc:cut copies the selection to the clipboard, then deletes it
Ctrl+C doc:copy no-op with no selection
Ctrl+V doc:paste
Backspace, Shift+Backspace doc:backspace deletes a whole indent step at a time in leading whitespace
Delete, Shift+Delete doc:delete at end of line, joins the next one first
Return doc:newline keeps the current line’s indentation
Ctrl+Return doc:newline-below
Ctrl+Shift+Return doc:newline-above
Ctrl+J doc:join-lines
Tab doc:indent indents the selection, or inserts one step
Shift+Tab doc:unindent
Ctrl+/ doc:toggle-line-comments
Ctrl+Up / Ctrl+Down doc:move-lines-up / doc:move-lines-down
Ctrl+Shift+D doc:duplicate-lines
Ctrl+Shift+K doc:delete-lines
doc:upper-case / doc:lower-case operate on the selection

Vim mode binds bare z and y to the same two commands. Those are not runtime bindings and do not appear in the table above — they belong to the vim plugin.

doc:delete is not a generated command. It combines two behaviours — clearing a run of whitespace at the end of a line, then deleting forward one character — so it is written by hand rather than coming from the translation table. Delete is bound to it and to nothing else; doc:delete-to-next-char exists, is available to a keymap, and has no default binding.

doc:toggle-line-comments needs a syntax definition. It reads doc.syntax.comment and returns without doing anything if there is none — so on a file with no matching definition the key is silently inert rather than an error. That is the same “no definition means plain text, and nothing warns you” rule as everywhere else in the highlighter.

Word deletion is a generated command with a different translation, which is why the binding list looks repetitive:

key command
Ctrl+Backspace, Ctrl+Shift+Backspace doc:delete-to-previous-word-start
Ctrl+Delete, Ctrl+Shift+Delete doc:delete-to-next-word-end

Movement

The movement set is generated from a table of sixteen translations (data/core/commands/doc.lua), which is what produces three commands per translation. move-to collapses a selection first; select-to extends it; delete-to removes up to it.

translation doc:move-to-… doc:select-to-… doc:delete-to-…
previous-char ← Shift+←
next-char → Shift+→
previous-word-start Ctrl+← Ctrl+Shift+← Ctrl+Backspace
next-word-end Ctrl+→ Ctrl+Shift+→ Ctrl+Delete
previous-block-start Ctrl+[ Ctrl+Shift+[
next-block-end Ctrl+] Ctrl+Shift+]
previous-line ↑ Shift+↑
next-line ↓ Shift+↓
start-of-line Home Shift+Home
end-of-line End Shift+End
start-of-doc Ctrl+Home Ctrl+Shift+Home
end-of-doc Ctrl+End Ctrl+Shift+End
previous-page PageUp Shift+PageUp
next-page PageDown Shift+PageDown
start-of-word
end-of-word

The delete-to column is mostly empty because only the two word translations have a default binding. All sixteen doc:delete-to-… commands exist and are available to a keymap; the ones with no key were simply never given one.

start-of-word and end-of-word have no binding in any column. They are there because the table they come from is shared with the word-selection logic, and a plugin that wants “select to the start of the word under the cursor” should not have to reimplement the translation.

Two of the move-to commands are not the generated ones. doc:move-to-previous-char and doc:move-to-next-char are overwritten with versions that collapse an existing selection to its edge first, so ← shrinks a selection and then starts moving. That is why the arrows behave the way they do, and it is a deliberate override rather than a special case in the table.

start-of-word and end-of-word do not appear in the movement table above because there is no default binding for them — see the paragraph above.

Selection

key command does
Ctrl+A doc:select-all
Ctrl+D doc:select-word the word under the cursor
Ctrl+L doc:select-lines whole lines, trailing newline included
Esc doc:select-none the second half of the Esc chain

<kbd>Esc</kbd> is bound to { "command:escape", "doc:select-none" }. The first closes an open prompt; the second clears a selection. Neither competes with the other, which is the point of the chain form.

Jumping

key command does
Ctrl+G doc:go-to-line

Type a number and it goes to that line. Type anything else and it fuzzy-matches the document’s lines, so a distinctive string is usually faster than counting. This is one of the few places the runtime opens a prompt itself, and it is here rather than in a plugin because a line number is a property of the document, not a workflow layered on top of one.

The editor

Always available. These are the whole of what the runtime does outside a document, and the line drawn is deliberate: could this work without any input from the user? core:new-doc can, so it is here. Anything that prompts for a path is a workflow, and workflows live in plugins.

key command does
Ctrl+N core:new-doc an empty document in the active pane
Alt+Return core:toggle-fullscreen
Ctrl+Shift+L core:open-log the log view — see The log
core:quit asks before discarding unsaved changes
core:force-quit does not ask

core:new-doc stays in the runtime even though opening files does not. A new empty document needs no extension, and the vim integrations and every plugin that opens a document assume it exists. The empty state of the editor has to be reachable with no plugins at all.

core:quit is a seam, not a command. It is the one function plugins wrap to run on exit. core:force-quit is the unwrap target; a plugin that calls it while wrapping core:quit skips its own confirmation, which is why the pair exists. See the extension contract.

Panes and splits

Available unless the active pane has a locked size — which is what a panel that opened itself as a locked pane (node:split(side, view, true), as cdin-x’s tree and its manager panel both do) gets while it holds focus. The bindings go quiet rather than fighting over a pane whose width the user cannot change from the document side.

key command does
Alt+Shift+J root:split-left
Alt+Shift+L root:split-right
Alt+Shift+I root:split-up
Alt+Shift+K root:split-down
Alt+J / L / I / K root:switch-to-… focus the neighbouring pane in that direction
Ctrl+W root:close close the active view
Ctrl+PageUp / PageDown root:move-pane-view-… reorder views inside the pane
Alt+1 … Alt+9 root:switch-to-pane-view-1 … -9

Also registered, unbound by default:

command does
root:switch-to-previous-pane-view / root:switch-to-next-pane-view cycle within the pane, wrapping
root:shrink / root:grow move the divider 0.1 toward this pane
root:split-{left,right,up,down} the four splits, by name

The mouse resizes any split, locked or not. Drag a divider and the pointer turns into a resize arrow over it; drag it and the pane on the other side takes the width. For a locked pane there is no divider for the layout to move, so the drag is routed to the pane itself as set_locked_size(axis, value) — which is why root:shrink / root:grow are unbound for one and the mouse is not. See side panels.

A pane holds several views; the layout is a tree. root:switch-to-pane-view-N reaches view N inside the active pane, which is not the same as tab N — tabs are a cdin-x plugin with its own concept, and the two used to share a word. The runtime’s names are the -pane-view- ones for that reason. The old -tab- spellings are still registered as aliases so existing keymaps keep working:

root:switch-to-previous-tab   =  root:switch-to-previous-pane-view
root:switch-to-next-tab       =  root:switch-to-next-pane-view
root:move-tab-left            =  root:move-pane-view-left
root:move-tab-right           =  root:move-pane-view-right
root:switch-to-tab-1 … -9     =  root:switch-to-pane-view-1 … -9

They are the same function objects, not wrappers, so binding one and running it is indistinguishable from binding the other.

root:split-* re-opens the document in the new pane. Splitting a document pane and being left with a blank one on one side is never what anyone wanted, so the active view is carried across. Splitting a non-document pane does nothing extra.

Focus is geometric, not ordered. root:switch-to-… looks for the pane overlapping a point just past the active pane’s edge, which is why it works for an uneven tree and why it refuses to move into a pane with a locked size.

Prompts

Available while core.command_view is open. This is the one prompt primitive in the runtime, and plugins build on it rather than each rolling their own.

key command does
Return, Keypad Enter command:submit run the current suggestion
Tab command:complete accept the highlighted suggestion
Esc command:escape close the prompt
↑ / ↓ command:select-previous / command:select-next move through suggestions

Every one of these is a fallback chain with a document command, which is why Return and Tab work everywhere:

return         →  { "command:submit",      "doc:newline" }
keypad enter   →  { "command:submit",      "doc:newline" }
tab            →  { "command:complete",    "doc:indent"   }
up             →  { "command:select-previous", "doc:move-to-previous-line" }
down           →  { "command:select-next",     "doc:move-to-next-line"     }
escape         →  { "command:escape",      "doc:select-none"            }

The log

Opened by Ctrl+Shift+L, which is core:open-log. That binding is the runtime’s own: the log view is a core view, so the key that opens it is core’s. It used to have none, and that made the log unreachable in a bare editor — the palette is a plugin, so with nothing installed there was no route to it and no error either.

key command does
Ctrl+Shift+L core:open-log the log view, in the active pane
Ctrl+C log:copy-selection the selected lines, or the whole visible log with no selection
Ctrl+A log:select-all selects the whole log
F2 log:switch-source switches between the two streams
Ctrl+R log:reload re-reads the current one

<kbd>Ctrl</kbd>+<kbd>C</kbd> and Ctrl+A are bound in this view in addition to their document bindings, and the log wins because it is the active view. That is not an override: the log’s four bindings are a second layer of the default keymap, added after the first, and keymap.add prepends — so Ctrl+C resolves to { "log:copy-selection", "doc:copy" } and falls through to the document when this view is not active.

Two streams, and which one you want

source what is in it where it comes from
editor every core.log / core.error, with tracebacks core.log_items, the last config.max_log_items of them
native C-level diagnostics and boot failures the C logger’s file, next to the binary

Both streams reach the file. The editor stream is mirrored into it, tagged LUA, with the traceback — CDIN_LUA_LOG=0 turns that off, and it is on by default. So the file holds a plugin traceback as well as the C lines, in the order things happened, while the view is the faster way to read the same thing. <kbd>F2</kbd> switches; the header names the source and its size, and Ctrl+R re-reads it, because a file being appended to while you read it is not a snapshot. Re-reading the editor stream is a re-snapshot of the buffer rather than a re-read, and it logs a log reloaded line, so the thing you are reading grows by one entry when you ask it to refresh.

The file is bounded and readable by default: CDIN_LOG_LEVEL sets the console level, CDIN_LOG_FILE_LEVEL the file’s, CDIN_LOG_FILE the path, and a log past 4 MB is rotated to cdin-log.txt.1 at startup rather than growing forever. The file defaults to DEBUG, which drops the per-frame renderer traces that used to fill it.

The empty view

Always available. Shown when a pane has no document.

command does
empty-view:open-file delegates to core:open-file
empty-view:open-folder delegates to core:open-folder
empty-view:open-recent-files whatever picker the recent provider wants
empty-view:open-recent-dirs the same, for directories

All four are no-ops on a bare editor. The first two delegate to core:open-file and core:open-folder by name, and both belong to cdin-x’s finder plugin. command.perform on an unregistered name returns false and does nothing, so with no plugin installed they do nothing rather than erroring — which is the behaviour the empty view wants, since a dead entry on the start screen is worse than a missing one.

The recent pair goes through core.recent_provider rather than a plugin command name, and no provider is registered by default, so they are no-ops too. They open whatever the registered provider wants to show, or nothing.

None of the four is bound to a key in the runtime. They are reachable from the palette — which is itself a plugin, so on a bare editor they are reachable from nowhere at all. Installing the finder is what gives them a keystroke.

On the empty view itself, one key works:

key does
Ctrl+N new document
↑ / ↓ printed on the screen; does nothing
Tab printed on the screen; does nothing
Return printed on the screen; does nothing
Esc printed on the screen; does nothing

That is a known gap rather than a design, and it is worth being precise about. The empty view implements on_key_pressed and handles all four of those keys there — but nothing in the runtime ever calls a view’s on_key_pressed. The keyboard path is events.lua → keymap.on_key_pressed and nothing else, so a key only ever reaches a command by name. Every command those four strokes name (command:select-next, command:complete, command:submit, command:escape) has a predicate that requires an open prompt, and the empty view has none, so each falls through and does nothing. Clicking an item with the mouse does work, because on_mouse_pressed is dispatched.

The list is deliberately short in the first place — no palette row, no find-file row, because printing a keystroke that does nothing is a lie the user has to debug — and installed plugins append their own entries through core.register_help_shortcuts, so what the screen claims is always exactly as long as what the runtime owns. Four of those five claims are currently wrong; the fix is a two-line dispatch in events.lua, not a redesign.

Plugins

Always available. These three load and unload plugins by name for one session; they are not how you manage extensions — the manager is, and it is in every build.

command does
core:list-plugins what is on disk in both roots, and which of it is loaded
core:load-plugin <name>… load by name, whatever config.plugins said
core:unload-plugin <name>… run its unload() and forget it

They take a comma-separated list, so core:load-plugin a,b,c works. With no argument they report what to do instead — core:list-plugins for the first — rather than failing silently.

These are the :packadd case. config.plugins decides what loads on its own; naming a plugin explicitly is how a bare editor stays useful rather than merely empty. It is deliberately not gated on the site set being enabled, so config.plugins = false still leaves you a way to bring one thing in.

They are not how you manage extensions. A build carries the extension manager, which is the panel you actually browse: it lists, searches, groups by category, enables, disables, installs and removes, and it is in every build because it is essential in cdin-x. Ctrl+Shift+M opens it (Shift+M in vim normal mode, when cdin-x’s vim-plugin-manager is installed), and it needs no cdin-x checkout: it downloads the catalog index and each extension you pick over HTTPS.

The panel is the only way to get the four workflow keys. These three commands work on what the loader can see — the two roots and nothing else — so they cannot install palette or finder and then have it load, because what the manager installs lives in a store the loader does not read. See extensions for the keys, the catalog and where everything lands.

Nothing here persists. There is no registry, no manifest and no saved state: what you load this way is gone at exit, and the only thing that survives a restart is what config.plugins asks for. Persistence is cdin-x’s job, and it is a job worth doing in a package manager rather than in the runtime.

Binding your own

-- ~/.config/cdin/user/init.lua
local keymap = require "core.input.keymap"
keymap.add { ["ctrl+g"] = "doc:go-to-line" }

Your file runs before any plugin, and keymap.add prepends. So the ordering that actually holds is:

  • the runtime’s default keymap is installed first, then
  • your init.lua, then
  • each plugin as it loads — each prepending in front of what is there

Which means a plugin’s binding wins over yours. You are not protected by load order; you are at the bottom of the stack. The way to beat a plugin is to replace the binding outright, not to set it again:

keymap.add({ ["ctrl+d"] = "doc:select-lines" }, true)   -- drops everything else on ctrl+d

Without the true, your command is prepended and the existing chain survives behind it — which is usually what you want (yours runs when its predicate holds, and the old one still works otherwise) and occasionally a surprise. That is why the flag is written at every call site in the tree rather than defaulted.

This is the whole reason your file runs where it does. Running it last would make it impossible for a plugin to bind a key you had already claimed, and cdin-x has no way to know which of your bindings it should respect.

To unbind, hand back the same shape:

keymap.remove { ["ctrl+d"] = "doc:select-lines" }

It detaches only the named commands, so another plugin bound to the same stroke keeps working.

Files

file holds
data/core/commands/ every command, one file per group
data/core/keymaps/default.lua every default binding
data/core/input/command.lua the registry, the predicates, perform
data/core/input/keymap.lua key resolution and the chain rule
data/core/views/commandview.lua the prompt primitive