vamp
A modal Rust editor with a replaceable frontend and subprocess-isolated Ruby plugins.
Run
Requires current stable Rust and an interactive terminal. Ruby 2.6+ with its standard-library JSON support is required only for configuration/plugins; no Ruby gems are needed.
cargo run -- --no-config notes.txt
cargo run -- --config examples/config.rb notes.txt
Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty buffer. Use :w path to save it. cargo run -- --help lists the command-line options.
Default editing keys
| Mode | Keys |
|---|---|
| Normal | i insert before cursor; a insert after cursor; o/O open line below/above |
| Normal | I (Shift+i) insert before the first nonblank, skipping spaces/tabs; A (Shift+a) insert at line end |
| Normal | h/j/k/l or arrows; 0/$ or Home/End |
| Normal | J (Shift+j) join the current line with the next line |
| Normal | x/Delete delete; u undo; U (Shift+u) redo; : enter command mode |
| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End; Esc returns to normal |
| Command | Enter executes; Backspace edits; Esc cancels |
J removes the next line's leading spaces/tabs and preserves existing trailing whitespace. It normally adds one space, two after ., ?, or ! (Vim's default), and none before ) or when either side is empty. The cursor lands at the join point; the join is one undo action. On the last line, J does nothing.
Commands: :w [path], :q, :q!, :wq [path], :plugins, and :plugin-command [arguments].
:q refuses unsaved changes. :wq quits only after a successful save. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode.
Paste is backend input: it is ignored in Normal and Command modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history.
Module seams
One crate, four modules, no workspace or frontend-specific types in the editing backend:
editor: owns buffer, cursor, modes, key notation and binding resolution, modal paste policy, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns anInvocationwhen an external command is selected.session:EditorSessionowns the editor and its Ruby hosts together. It loads configuration, registers eligible plugins before installing bindings, executes selected commands, and retires both host and command ownership on invocation or effect-validation failure.tui: translates terminal events to session input, renders read-only editor state, and advertisestextandstyled_textcapabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.ruby: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.- The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI.
Frontends drive EditorSession::handle or execute and inspect editor(); there is no mutable editor getter that could independently alter plugin registration. Pasted text uses Input::Paste(String) through the same input seam as keys. Plugin effects are applied internally so a rejected result also retires its owner; there is no generic frontend effects entry point.
The terminal adapter owns raw mode, alternate-screen state, bracketed paste, and its panic hook for one session lifetime. Entry failure, normal return, I/O errors, and unwinding panics restore the caller's hook; active-terminal cleanup runs before caller panic diagnostics. Panics retain their original payload and propagate rather than being swallowed. Terminal state and panic hooks are process-global: run one TUI at a time and do not replace its hook while that session is active.
Built-in Ex commands register as the protected core feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, or disable core. User configuration can override any keybinding without replacing the core implementation. :plugins lists active features and frontend capabilities.
A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, Vamp.plugin "canvas", requires: ["graphics"] will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions.
Ruby configuration
By default, vamp loads $XDG_CONFIG_HOME/vamp/config.rb, or ~/.config/vamp/config.rb when that environment variable is unset. --config path selects a file explicitly; --no-config bypasses Ruby entirely. Set VAMP_RUBY to select a different Ruby executable.
Vamp.configure do |config|
config.plugin "plugins/greeting.rb"
config.bind "g", "greet"
config.bind "ctrl+r", "core.redo"
config.bind "shift+j", "core.join_lines"
config.bind "ctrl+c", "core.normal", mode: "insert"
end
Plugin paths resolve relative to the config file, not the shell's working directory. config.bind key, target, mode: "normal" overrides one key in normal, insert, or command mode; the mode defaults to normal. Targets are the built-in actions below or exact registered Ex/plugin command names ("w", "greet", etc., without : or arguments). Ex/plugin commands can only be bound in Normal mode.
Key notation follows Herdr's readable style. Literal non-control Unicode characters are case-sensitive (n, J, +); named keys and modifiers are case-insensitive:
- Named keys:
esc,enter,backspace,delete,left,right,up,down,home,end,tab,space. - Modifiers:
ctrl+athroughctrl+z, andshift+athroughshift+z.shift+jandJaddress the same key. - Named punctuation:
minus,comma,ampersand,plus,backtick. Each addresses the same binding as its literal character.
Terminals share ctrl+h with backspace, ctrl+i with tab, and ctrl+j/ctrl+m with enter; these names address the same binding. Only a single modifier on an ASCII letter is supported. Key sequences, prefix mode, Alt/Cmd/Super, combined modifiers, modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key.
| Available modes | Built-in targets |
|---|---|
| Normal | core.insert, core.insert_first_nonblank, core.append, core.append_line_end, core.open_below, core.open_above |
| Normal | core.join_lines, core.undo, core.redo, core.command |
| Normal, Insert | core.left, core.down, core.up, core.right, core.line_start, core.line_end, core.delete |
| Insert, Command | core.normal, core.backspace |
| Insert | core.newline |
| Command | core.submit |
Adding a binding keeps the original key. Pass nil to disable a key, including literal typing in Insert/Command mode:
Vamp.configure do |config|
config.bind "n", "core.insert"
config.bind "i", nil
config.bind "space", "core.command"
config.bind ":", nil
end
The last valid declaration for a key/mode wins. Invalid keys, modes, or targets leave that binding unchanged and appear in the status message; other valid bindings still load. Ruby syntax/type errors disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its bindings, without restoring any default keys they replaced.
Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command mode or entering commands, restart with --no-config to restore defaults.
Ruby plugins
Vamp.plugin "greeting", requires: ["text"]
Vamp.command "greet" do |editor, args|
name = args.empty? ? "world" : args
editor.insert "Hello, #{name}!\n"
editor.message "Greeting inserted by Ruby"
end
Run :greet vamp, or press g with the example configuration. examples/plugins/greeting.rb also provides :upper to uppercase the buffer.
Callbacks receive an immutable context snapshot: editor.text, editor.row, editor.column, editor.mode, and editor.path. Row/column are one-based grapheme positions; mode is NORMAL; path is a string or nil. Arguments are the remaining command text, without surrounding whitespace.
editor.insert(text), editor.replace(text), and editor.message(text) queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin puts, explicit STDOUT.write, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
This is crash isolation, not a security sandbox: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
Messaging
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with version: 1. Startup returns manifest (name, required capabilities, commands) or config (plugin paths and bindings). invoke requests carry an ID, command, arguments, and context; result replies carry the matching ID and typed effects (insert, replace, message). Errors use error frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
JSON uses Ruby's standard library. Protocol Buffers is not installed or required; there is no frontend/network wire protocol yet. The frontend currently calls the Rust library directly.
Current limits
- One text buffer; Normal, Insert, and Command modes. No Visual mode, Vim counts/operators, splits, search, LSP, or graphical frontend.
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
- Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing.
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
Verify
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt --check
ruby -c src/ruby_host.rb
Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. Backend paste checks cover ignored modes, literal Unicode/newline insertion, rejection atomicity, and typing/paste/typing undo and redo. Session checks join real Ruby execution to capability/collision rejection, complete command/binding retirement after invalid effects or host failure, surviving plugins, core editing/saving, and broken-config fallback. The executable was also exercised in PTY sessions for editing/saving, rejected-effect retirement, plugin crash and hang recovery, modal bracketed paste, rejected-paste feedback, undo/redo, scrolling, resize, and terminal restoration.
Unix terminal-hook checks run in isolated child processes and real PTYs to keep global hooks out of the parallel test runner. They cover entry failure, normal return, I/O error, panic cleanup before caller diagnostics, original panic-payload propagation, and later panics producing no stale terminal-restoration output.