Minga is configured with real Elixir code. Your config file is ~/.config/minga/config.exs (or $XDG_CONFIG_HOME/minga/config.exs). Press SPC f p to open it from inside the editor.
If the file doesn't exist, SPC f p creates it with a starter template.
Quick start
use Minga.Config
set :tab_width, 4
set :line_numbers, :relative
set :scroll_margin, 8That's it. Save the file and restart Minga. Your options take effect immediately on startup.
Options
set/2 configures global editor options. These are the supported options:
| Option | Type | Default | Description |
|---|---|---|---|
:tab_width | positive integer | 2 | Number of spaces per indent level |
:line_numbers | :hybrid, :absolute, :relative, :none | :hybrid | Line number display style |
:autopair | boolean | true | Auto-insert matching brackets and quotes |
:scroll_margin | non-negative integer | 5 | Lines to keep visible above/below cursor when scrolling |
:theme | theme name atom | :doom_one | Color theme (see Themes below) |
:indent_with | :spaces or :tabs | :spaces | Whether to indent with spaces or tab characters |
:trim_trailing_whitespace | boolean | false | Strip trailing whitespace on save |
:insert_final_newline | boolean | false | Ensure file ends with a newline on save |
:format_on_save | boolean | false | Run the filetype's formatter before saving |
:formatter | string or nil | nil | Override the default formatter command (see Formatters) |
:title_format | string | "{filename} {dirty}({directory}) - Minga" | Terminal window title format (see Window Title) |
:recent_files_limit | positive integer | 200 | Max recent files tracked per project |
:persist_recent_files | boolean | true | Write recent file history to disk (see Projects) |
:agent_tool_approval | :destructive, :all, :none | :destructive | When to prompt before executing agent tools |
:agent_destructive_tools | list of strings | ["write_file", "edit_file", "shell"] | Which tools are classified as destructive |
:agent_session_retention_days | positive integer | 30 | Days to keep saved agent sessions before auto-pruning |
:startup_view | :agent or :editor | :agent | Which view to show on startup (see Startup view below) |
:agent_auto_context | boolean | true | Load CLI file as agent preview context on startup |
:whichkey_layout | :bottom or :float | :bottom | Which-key popup display mode (see Which-key popup below) |
:font_family | string | "Menlo" | Font family or name (see Fonts below) |
:font_size | positive integer | 13 | Font size in points (see Fonts below) |
:font_weight | weight atom | :regular | Font weight (see Fonts below) |
:font_ligatures | boolean | true | Enable programming ligatures (see Fonts below) |
:log_level | :debug, :info, :warning, :error, :none | :info | Global minimum log level (see Logging below) |
:log_level_render | log level or :default | :default | Log level for the render pipeline |
:log_level_lsp | log level or :default | :default | Log level for LSP client communication |
:log_level_agent | log level or :default | :default | Log level for AI agent providers |
:log_level_editor | log level or :default | :default | Log level for general editor operations |
set :tab_width, 2
set :line_numbers, :hybrid
set :autopair, true
set :scroll_margin, 5
set :theme, :catppuccin_mocha
set :font_family, "JetBrains Mono"
set :font_size, 14
set :font_weight, :regular
set :font_ligatures, trueInvalid values show a clear error. Setting :tab_width to -1 tells you it must be a positive integer.
Agent tool approval
When the AI agent wants to run a destructive tool (writing a file, editing a file, or running a shell command), Minga pauses and shows a confirmation prompt:
⚠ Execute shell: mix test? [y]es [n]o [a]ll- y or Enter approves the tool and lets it run.
- n rejects the tool. The agent gets "Tool rejected by user" as the result and continues its turn.
- a approves this tool and all remaining tools in the current turn without further prompts.
The approval mode resets at the start of each new agent turn.
Controlling when approval is required
The :agent_tool_approval option controls the gate:
# Default: prompt only for destructive tools
set :agent_tool_approval, :destructive
# Prompt for every tool call (read_file, list_directory, etc.)
set :agent_tool_approval, :all
# Auto-approve everything, never prompt
set :agent_tool_approval, :noneCustomizing the destructive tools list
The :agent_destructive_tools option controls which tools are classified as destructive. Only tools in this list trigger the approval prompt when agent_tool_approval is :destructive.
# Default
set :agent_destructive_tools, ["write_file", "edit_file", "shell"]
# Trust file edits, only prompt for shell commands
set :agent_destructive_tools, ["shell"]
# Add a custom tool to the list
set :agent_destructive_tools, ["write_file", "edit_file", "shell", "deploy"]
# Empty list with :destructive mode = no prompts for built-in tools
set :agent_destructive_tools, []The two options are orthogonal. :agent_tool_approval controls whether to prompt. :agent_destructive_tools controls which tools count as destructive when the mode is :destructive.
For the full option API, see Minga.Config.Options.
Planned: buffer-aware agent options
When agent tools are routed through Buffer.Server, additional configuration options will control the new editing behavior:
- Whether agent edits auto-save to disk or stay in-memory until explicit save
- Whether buffer forks merge automatically (clean merges only) or always go through diff review
- Whether to flush dirty buffers before agent shell commands
These options don't exist yet. See BUFFER-AWARE-AGENTS.md for the design.
Startup view
Minga boots into the full-screen agentic view by default. The chat panel is visible, the input is focused, and an agent session starts automatically. You're ready to talk to the agent the moment Minga opens.
If you pass a file on the command line (minga foo.ex), the file opens in the preview pane so the agent has context about what you're working on.
Switching to editor-first startup
If you prefer the traditional file editing experience on startup, set :startup_view to :editor:
set :startup_view, :editorThis restores the pre-1.0 behavior: Minga opens with a file buffer (or scratch buffer) and the agentic view is a toggle away via SPC a t.
Controlling auto-context
When the agentic view is the startup view and you open a file from the CLI, the file's content is loaded into the preview pane by default. This makes the interaction feel like "I'm chatting about this file" rather than "I opened a file and there's a chat panel."
To disable this and start with a blank agentic view even when a file is provided:
set :agent_auto_context, falseThe file is still opened in a buffer (accessible via SPC b b), it just isn't surfaced in the preview pane automatically.
CLI flag overrides
CLI flags override config options for a single invocation:
# Force editor mode regardless of config
minga --editor foo.ex
# Agentic view but don't load the file as context
minga --no-context foo.ex
# Editor mode with no file (scratch buffer)
minga --editor
Summary of combinations
| Config | CLI | Result |
|---|---|---|
startup_view: :agent (default) | minga | Agentic view, empty |
startup_view: :agent (default) | minga foo.ex | Agentic view, file in preview |
startup_view: :agent, agent_auto_context: false | minga foo.ex | Agentic view, preview empty, file in buffer list |
startup_view: :agent | minga --no-context foo.ex | Same as above |
startup_view: :agent | minga --editor foo.ex | Editor view, file open |
startup_view: :editor | minga foo.ex | Editor view, file open |
startup_view: :editor | minga | Editor view, scratch buffer |
Which-key popup
When you press a leader key (like SPC) and pause, a which-key popup appears showing all available continuations. By default it renders as a horizontal bar anchored to the bottom of the viewport (like Doom Emacs):
set :whichkey_layout, :bottom # defaultSet it to :float to render the which-key popup as a centered floating window with a rounded border and title:
set :whichkey_layout, :floatThe float layout uses the same FloatingWindow primitive as the centered picker. It auto-sizes to fit the bindings (up to 70% width and 60% height of the viewport) and uses your theme's popup colors for the background, text, and border.
Both layouts respond to mouse clicks. In float mode, clicking outside the popup dismisses it.
Themes
Minga ships 7 built-in color themes. Set one in your config and restart, or browse them live with SPC h t.
set :theme, :catppuccin_mocha| Theme | Style |
|---|---|
:doom_one | Dark (default), Doom Emacs |
:catppuccin_frappe | Dark, Catppuccin family |
:catppuccin_latte | Light, Catppuccin family |
:catppuccin_macchiato | Dark, Catppuccin family |
:catppuccin_mocha | Dark, Catppuccin family |
:one_dark | Dark, Atom |
:one_light | Light, Atom |
The theme picker (SPC h t) live-previews each theme as you navigate the list. Selecting one applies it for the current session. To make it permanent, add the set :theme line to your config file.
For theme internals, see Minga.Theme.
Fonts
Font settings only apply to the GUI backend. In TUI mode (the default), your terminal controls the font. Change your font in your terminal emulator's preferences (Ghostty, Kitty, iTerm2, WezTerm, etc.) instead. The font options are accepted in TUI mode without error, they just have no effect.
set :font_family, "JetBrains Mono"
set :font_size, 14
set :font_weight, :light
set :font_ligatures, trueFont weight
Available weights: :thin, :light, :regular, :medium, :semibold, :bold, :heavy, :black. The default is :regular.
set :font_weight, :light # thinner strokes, popular for high-DPI
set :font_weight, :regular # default
set :font_weight, :medium # slightly heavier than regularNot every font ships every weight. If the requested weight isn't available, macOS picks the closest match. Bold text from syntax highlighting still resolves to the font's bold variant regardless of this setting.
You can use any of these name formats:
- Family name:
"Fira Code","JetBrains Mono","Menlo" - PostScript name:
"FiraCode-Regular","JetBrainsMonoNF-Regular"
If the font isn't found, Minga falls back to the system monospace font and logs a warning to *Messages*. The default is "Menlo" at size 13, which ships with every Mac.
Programming ligatures
Programming ligatures combine multi-character sequences like ->, !=, =>, <=, :: into single visual glyphs. They're enabled by default:
set :font_ligatures, true # default
set :font_ligatures, false # render each character individuallyLigatures only work when two conditions are met:
:font_ligaturesistrue- The font has ligature tables (Fira Code, JetBrains Mono, Cascadia Code, etc.)
Fonts without ligature tables (like Menlo or SF Mono) are unaffected by this setting. Setting :font_ligatures to false is useful if you're using a ligature font but prefer to see individual characters.
Logging
Minga logs to *Messages* (viewable with SPC b m) and to ~/.local/share/minga/minga.log. By default, the log level is :info, which suppresses noisy debug output like render pipeline timing.
# Set the global floor (suppresses :debug by default)
set :log_level, :infoEach subsystem has its own log level override. Set a subsystem to :debug to see its detailed output without turning on debug logs everywhere:
# Debug just the render pipeline
set :log_level_render, :debug
# Debug LSP communication
set :log_level_lsp, :debugThe subsystem options default to :default, which means "inherit from :log_level." You can also set a subsystem to :none to silence it completely, even for warnings and errors.
Subsystems
| Subsystem | Option | What it covers |
|---|---|---|
| render | :log_level_render | Render pipeline stage timing and frame composition |
| lsp | :log_level_lsp | LSP server communication, requests, and errors |
| agent | :log_level_agent | AI agent providers, sessions, and tool execution |
| editor | :log_level_editor | General editor operations, commands, and file I/O |
Example: debugging a rendering issue
set :log_level, :warning # quiet everything else
set :log_level_render, :debug # but show render pipeline detailsOpen *Messages* with SPC b m to see the per-stage timing output in real time.
Window title
Minga updates the terminal window title to reflect the active buffer, just like Neovim and Doom Emacs. The default format is {filename} {dirty}({directory}) - Minga, which produces titles like editor.ex (lib) - Minga or editor.ex [+] (lib) - Minga for modified files.
Customize it with :title_format:
# Just the filename
set :title_format, "{filename} - Minga"
# Include the mode
set :title_format, "{filename} {dirty}[{mode}] - Minga"
# Full path, no branding
set :title_format, "{filepath}"Available placeholders:
| Placeholder | Expands to |
|---|---|
{filename} | File basename (e.g. editor.ex) |
{filepath} | Full file path |
{directory} | Parent directory name |
{dirty} | [+] if modified, empty otherwise |
{readonly} | [-] if read-only, empty otherwise |
{mode} | Current mode in uppercase (e.g. NORMAL, INSERT) |
{bufname} | Buffer display name (filename, or *scratch* for unnamed buffers) |
The title is restored to its previous value when Minga exits.
Per-filetype settings
Different languages have different conventions. for_filetype/2 overrides global options for buffers of a specific language:
for_filetype :go, tab_width: 8
for_filetype :python, tab_width: 4
for_filetype :elixir, tab_width: 2, autopair: trueWhen you open a Go file, tab_width is 8. When you open an Elixir file, it's 2. Buffers with no filetype (or filetypes without overrides) use the global value.
You can set any option that set/2 accepts. The filetype atom matches what Minga.Filetype detects (:elixir, :go, :python, :rust, :javascript, etc.).
Formatters
SPC c f formats the current buffer using the configured formatter for its filetype. Minga ships default formatters for common languages:
| Language | Default formatter |
|---|---|
| Elixir | mix format --stdin-filename {file} - |
| Go | gofmt |
| Rust | rustfmt --edition 2021 |
| Python | python3 -m black --quiet - |
| JavaScript/TypeScript | prettier --stdin-filepath {file} |
| Zig | zig fmt --stdin |
| C/C++ | clang-format |
The {file} placeholder is replaced with the buffer's file path (useful for formatters that need it to find their config).
Format-on-save
Enable per-filetype with for_filetype:
for_filetype :elixir, format_on_save: true
for_filetype :go, format_on_save: true, indent_with: :tabs, tab_width: 8
for_filetype :rust, format_on_save: trueWhen you save (:w or SPC f s), the buffer is formatted before writing to disk. If the formatter fails, the buffer is saved unformatted and the error appears in the status bar.
Custom formatters
Override the default formatter for any filetype:
for_filetype :elixir, formatter: "mix format --stdin-filename {file} -"
for_filetype :ruby, formatter: "rubocop --stdin {file} --autocorrect"
for_filetype :javascript, formatter: "deno fmt --ext=js -"Save transforms
Two additional options clean up whitespace on save:
# Strip trailing whitespace from every line
for_filetype :elixir, trim_trailing_whitespace: true
# Ensure the file ends with a newline
for_filetype :elixir, insert_final_newline: trueThese run before format-on-save, so the formatter gets clean input.
Syntax highlighting
Minga uses tree-sitter for syntax highlighting. 39 languages are supported out of the box. Highlighting activates automatically when a file's type is detected. No configuration needed.
Supported languages
| Language | Extensions |
|---|---|
| Bash | .sh, .bash, .zsh |
| C | .c, .h |
| C# | .cs, .csx |
| C++ | .cpp, .cc, .cxx, .hpp |
| CSS | .css |
| Dart | .dart |
| Diff | .diff, .patch |
| Dockerfile | Dockerfile |
| Elisp | .el |
| Elixir | .ex, .exs |
| Erlang | .erl, .hrl |
| Gleam | .gleam |
| Go | .go |
| GraphQL | .graphql, .gql |
| Haskell | .hs, .lhs |
| HCL / Terraform | .tf, .tfvars, .hcl |
| HEEx | .heex, .leex |
| HTML | .html, .htm |
| Java | .java |
| JavaScript | .js, .mjs, .cjs, .jsx |
| JSON | .json, .jsonc |
| Kotlin | .kt, .kts |
| Lua | .lua |
| Make | Makefile, .mk, .mak |
| Markdown | .md, .markdown |
| Nix | .nix |
| OCaml | .ml, .mli |
| PHP | .php, .phtml |
| Python | .py, .pyi |
| R | .r, .rmd |
| Ruby | .rb, .rake, .gemspec |
| Rust | .rs |
| Scala | .scala, .sbt, .sc |
| SCSS | .scss, .sass |
| TOML | .toml |
| TSX | .tsx |
| TypeScript | .ts, .mts, .cts |
| YAML | .yaml, .yml |
| Zig | .zig, .zon |
Customizing highlight colors
Highlight colors come from your theme. Switching themes (set :theme, :catppuccin_mocha) changes all syntax colors at once. See Themes above.
Overriding highlight queries
Each language's highlighting is driven by a tree-sitter query file (.scm). If you want to change what gets highlighted (for example, adding a capture for a language feature the default query misses), drop a custom query file at:
~/.config/minga/queries/{language}/highlights.scmFor example, to customize Elixir highlighting:
~/.config/minga/queries/elixir/highlights.scmYour custom query completely replaces the built-in one for that language. Start by copying the default query from the Minga source (zig/src/queries/{language}/highlights.scm) and modifying it.
Queries use tree-sitter's query syntax. The capture names Minga recognizes include: @keyword, @string, @comment, @function, @type, @number, @operator, @punctuation, @variable, @constant, and more. Each maps to a color slot in the active theme.
No restart is needed after changing a query file; SPC h r (hot reload) picks up the change.
Custom captures with custom face styling
The full highlight customization flow lets you define your own capture names in a query file, then style them with custom faces in a theme. This is how you'd highlight something the default query doesn't distinguish.
Example: highlight Elixir pipe operators differently from other operators.
- Add a custom capture in your query. Copy the default Elixir query and add a specific capture:
; ~/.config/minga/queries/elixir/highlights.scm
; (copy of the default query, plus:)
(binary_operator
operator: "|>" @operator.pipe)The capture @operator.pipe is a new name you invented. It follows the dotted naming convention so it inherits from @operator by default.
- Define a face for the capture in your theme file. In your config:
# In config.exs
Minga.Face.Registry.put(face_registry, %Minga.Face{
name: "operator.pipe",
inherit: "operator",
fg: 0x51AFEF,
bold: true
})Or in a theme TOML file (when theme file loading is available):
[faces."operator.pipe"]
inherit = "operator"
fg = "51AFEF"
bold = true- Reload. Press
SPC h rto pick up the query change. The pipe operator now renders in blue bold, while other operators keep their default style.
How it works under the hood:
- Tree-sitter matches
@operator.pipecaptures in the query and sends them to the BEAM. - The face registry resolves
"operator.pipe"by walking the inheritance chain:operator.pipe→operator→default. Your custom face overrides fg and bold; everything else inherits from the parent. - The render pipeline uses the resolved face for styling. No special registration needed for new capture names; the dotted-name convention handles inheritance automatically.
Built-in capture names that themes can style include: keyword, keyword.function, keyword.operator, string, string.special, comment, comment.documentation, function, function.method, function.builtin, function.macro, type, type.builtin, variable, variable.builtin, variable.parameter, constant, constant.builtin, number, boolean, operator, punctuation.delimiter, punctuation.bracket, punctuation.special, attribute, property, tag, label, namespace, module, constructor, and more. Any dotted sub-capture (e.g., keyword.return) inherits from its parent (e.g., keyword) if no explicit face is defined.
Registering custom filetypes
If Minga doesn't recognize a file extension, you can register it in your config so the right grammar is used:
# In config.exs
Minga.Filetype.Registry.register(".astro", :astro)
Minga.Filetype.Registry.register("Justfile", :just)This tells Minga the filetype, but highlighting only works if a grammar for that language is compiled into the editor. See below for adding new grammars.
Adding a new language grammar
Adding a grammar for a language Minga doesn't ship requires building from source. This involves five steps:
- Vendor the grammar: copy the tree-sitter grammar's
src/directory intozig/vendor/grammars/{lang}/src/. You needparser.cand optionallyscanner.c. - Add a highlight query: place a
highlights.scmatzig/src/queries/{lang}/highlights.scm. The grammar's upstream repo usually has one you can start from. - Register in the Zig build: add an entry to the
grammarsarray inzig/build.zigwithhas_scannerset appropriately. - Register in the highlighter: in
zig/src/highlighter.zig, add anextern fn tree_sitter_{lang}()declaration and an entry in thelanguagesarray. - Register the filetype: add extension/filename mappings in
lib/minga/filetype.exso files are detected correctly.
After rebuilding (mix compile), the grammar is compiled into the binary and available immediately.
If you add a grammar for a popular language, consider opening a PR so everyone gets it.
Prettify symbols
When enabled, prettify-symbols replaces common operator text with Unicode equivalents in the display without modifying the buffer. For example, -> renders as →, != as ≠, and fn as λ in Elixir.
# In config.exs
set :prettify_symbols, trueThis is off by default since it's a matter of taste. The substitutions are filetype-aware and only apply to actual operators (not text inside strings or comments), because they use tree-sitter highlight captures to identify what's an operator.
Built-in substitutions include:
| Source | Display | Languages |
|---|---|---|
-> | → | All |
=> | ⇒ | All |
<- | ← | All |
!= | ≠ | All |
>= | ≥ | All |
<= | ≤ | All |
|> | ▷ | All |
fn | λ | Elixir, Rust |
lambda | λ | Python |
The buffer content is never modified; only the display changes. Cursor movement skips over concealed text in normal mode and reveals it in insert mode (same as Neovim's conceallevel=2).
Keybindings
bind/4 adds or overrides keybindings in any vim mode:
# Normal mode: leader sequences and single-key overrides
bind :normal, "SPC g s", :git_status, "Git status"
bind :normal, "SPC g b", :git_blame, "Git blame"
bind :normal, "Q", :replay_last_macro, "Replay last macro"
# Insert mode
bind :insert, "C-j", :next_line, "Next line"
bind :insert, "C-k", :prev_line, "Previous line"
# Visual mode
bind :visual, "C-x", :custom_cut, "Custom cut"The first argument is the mode: :normal, :insert, :visual, :operator_pending, or :command. The second is a space-separated key sequence string. Special keys:
| Token | Key |
|---|---|
SPC | Space |
TAB | Tab |
RET | Return/Enter |
ESC | Escape |
C-x | Ctrl + x |
M-x | Alt/Meta + x |
User bindings override defaults. If you bind SPC f f to something else, it replaces the built-in "Find file" binding.
Invalid key sequences log a warning but don't crash the editor.
Filetype-scoped bindings (SPC m)
SPC m is reserved for filetype-specific leader bindings. Use the keymap block to define them:
keymap :elixir do
bind :normal, "SPC m t", :mix_test, "Run tests"
bind :normal, "SPC m f", :mix_format, "Format with mix"
bind :normal, "SPC m r", :iex_run, "Run in IEx"
end
keymap :go do
bind :normal, "SPC m t", :go_test, "Go test"
bind :normal, "SPC m b", :go_build, "Go build"
endYou can also use the explicit filetype: option for one-off bindings:
bind :normal, "SPC m p", :markdown_preview, "Preview", filetype: :markdownDifferent filetypes can use the same sub-key. SPC m t runs mix test in an Elixir buffer but go test in a Go buffer. The which-key popup shows only the bindings for the current buffer's filetype.
Scope-specific bindings
Override or extend bindings for specific keymap scopes using a {scope, vim_state} tuple:
# Override agent scope keys
bind {:agent, :normal}, "y", :my_custom_yank, "Custom yank"
bind {:agent, :normal}, "~", :toggle_debug, "Toggle debug"
# Override file tree scope keys
bind {:file_tree, :normal}, "d", :tree_delete, "Delete file"Keymap scopes
Minga uses keymap scopes to provide view-type-specific keybindings. Three scopes ship by default: :editor (normal editing), :agent (agentic view), and :file_tree (file tree panel). Leader key bindings (SPC ...) work identically in all scopes.
For the full scope architecture and resolution order, see Keymap Scopes.
For key parser internals, see Minga.Keymap.KeyParser. For how the keymap trie works, see Minga.Keymap.Store and Minga.Keymap.Trie.
Custom commands
command/3 defines a named command that can be bound to keys and appears in the command palette (SPC :):
command :count_todos, "Count TODOs in buffer" do
content = Minga.API.content()
count = content |> String.split("\n") |> Enum.count(&String.contains?(&1, "TODO"))
Minga.API.message("#{count} TODOs found")
end
# Bind it to a key
bind :normal, "SPC c t", :count_todos, "Count TODOs"Commands run inside a supervised Task. If your command raises an exception, the error shows in the status bar but the editor keeps running. You can't crash the editor with a buggy command.
The Minga.API module provides a user-friendly interface for common operations inside commands: content/0, insert/1, cursor/0, move_to/2, save/0, message/1, and more.
Lifecycle hooks
on/2 registers functions that fire on editor events:
on :after_save, fn _buffer_pid, path ->
if String.ends_with?(path, ".ex") do
System.cmd("mix", ["format", path])
end
end
on :after_open, fn _buffer_pid, path ->
Minga.API.message("Opened: #{Path.basename(path)}")
end
on :on_mode_change, fn old_mode, new_mode ->
IO.puts("Mode: #{old_mode} -> #{new_mode}")
endSupported events
| Event | Arguments | Fires when |
|---|---|---|
:after_save | (buffer_pid, file_path) | After a successful file save |
:after_open | (buffer_pid, file_path) | After opening a file |
:on_mode_change | (old_mode, new_mode) | When the editor mode changes |
Hooks run asynchronously. A slow :after_save hook (like running a formatter) won't block your typing. Each hook runs in its own supervised process, so crashes are logged but don't affect the editor.
Multiple hooks on the same event fire in registration order.
For the hooks API, see Minga.Config.Hooks.
Command advice
advise/3 wraps an existing command with before or after logic. This is similar to Emacs's advice system, but crash-isolated.
Four phases are supported, matching Emacs's advice system:
| Phase | Signature | Behavior |
|---|---|---|
:before | fn state -> state end | Transforms state before the command |
:after | fn state -> state end | Transforms state after the command |
:around | fn execute, state -> state end | Receives the original command; decides whether/how to call it |
:override | fn state -> state end | Completely replaces the command |
:around is the most powerful. It receives the original command as a function, so you can conditionally skip it, call it multiple times, or wrap it with custom logic:
# Only format if there are no diagnostics errors. If the buffer has
# errors, formatting often makes things worse.
advise :around, :format_buffer, fn execute, state ->
errors =
state.buffers.active
|> Minga.Diagnostics.for_buffer()
|> Enum.count(fn d -> d.severity == :error end)
if errors == 0 do
execute.(state)
else
%{state | status_msg: "Format skipped: #{errors} error(s)"}
end
end:override replaces a command entirely. :before and :after still run around an overridden command, so you can override the core behavior while keeping other advice intact:
# Replace the built-in save with one that also stages the file in git
advise :override, :save, fn state ->
# Run the original save logic
state = Minga.API.save()
# Then auto-stage
case Minga.Buffer.Server.file_path(state.buffers.active) do
nil -> state
path ->
System.cmd("git", ["add", path], stderr_to_stdout: true)
%{state | status_msg: "Saved and staged: #{Path.basename(path)}"}
end
end:before and :after handle simpler cases where you just need to transform state on the way in or out:
# Ensure cursor is at line start before any search, so results
# are consistent regardless of where you were on the line
advise :before, :search_project, fn state ->
Minga.API.move_to_column(0)
state
endMultiple advice functions for the same phase and command run in registration order. For :around, they nest outward (first registered is outermost). Crashes in any advice are logged and skipped; the editor keeps running.
When to use advice vs. hooks
Hooks (on/2) are for fire-and-forget side effects: running an external tool after save, logging, sending notifications. They run asynchronously and can't change editor state.
Advice (advise/3) is for changing how a command behaves: conditionally skipping it, transforming editor state before or after it runs, or replacing the command entirely. Advice runs synchronously as part of the command, so it can affect what happens next.
For the advice API, see Minga.Config.Advice.
Error handling
Minga is forgiving about config errors:
- No config file: editor starts with defaults, no error
- Syntax error in config: editor starts with defaults, error shown in status bar
- Runtime error (e.g., invalid option value): editor starts with defaults, error shown in status bar
- Command crashes: error shown in status bar, editor keeps running
- Hook crashes: error logged, other hooks still fire, editor keeps running
You can check for config load errors programmatically with Minga.Config.Loader.load_error/0.
User modules
For anything beyond a quick command, you can write full Elixir modules. Drop .ex files in ~/.config/minga/modules/ and they're compiled and loaded at startup.
# ~/.config/minga/modules/todo_tools.ex
defmodule TodoTools do
def count(text) do
text |> String.split("\n") |> Enum.count(&String.contains?(&1, "TODO"))
end
def list(text) do
text
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.filter(fn {line, _} -> String.contains?(line, "TODO") end)
|> Enum.map(fn {line, num} -> "#{num}: #{String.trim(line)}" end)
|> Enum.join("\n")
end
endThen reference it in your config:
# ~/.config/minga/config.exs
use Minga.Config
command :list_todos, "List TODO lines" do
{:ok, content} = Minga.API.content()
Minga.API.message(TodoTools.list(content))
end
bind :normal, "SPC c t", :list_todos, "List TODOs"If a module has a compile error, Minga logs a warning and skips that file. Other modules and your config still load normally.
Project-local config
Drop a .minga.exs file in your project root to set project-specific options. Project config runs after global config, so it overrides global settings.
# /path/to/my-project/.minga.exs
use Minga.Config
set :tab_width, 4
for_filetype :go, tab_width: 8
on :after_save, fn _buf, path ->
if String.ends_with?(path, ".go"), do: System.cmd("gofmt", ["-w", path])
endThis is useful for team-shared settings. Check .minga.exs into your repo and everyone on the team gets the same tab width, formatters, and hooks.
Note: Project-local config is real Elixir code that runs when you open the editor in that directory. Review .minga.exs files in untrusted projects before opening them.
Post-init hook (after.exs)
If you need config that depends on user modules being loaded first, put it in ~/.config/minga/after.exs. This file runs last, after modules, global config, and project config.
# ~/.config/minga/after.exs
use Minga.Config
# This works because TodoTools was compiled from modules/ first
set :tab_width, TodoTools.preferred_tab_width()Hot reload
Press SPC h r to reload your config without restarting the editor. This:
- Stops all running extensions
- Purges user modules
- Resets options, keybindings, hooks, advice, and commands to defaults
- Re-compiles modules, re-evaluates config.exs, .minga.exs, and after.exs
- Restarts extensions
Changed keybindings and options take effect immediately. The status bar shows "Config reloaded" on success or an error message if something went wrong.
You can also reload from the command line with :reload-config (not yet wired as an ex command, use SPC h r).
Load order
Minga loads config in this order. Each stage can override the previous one:
~/.config/minga/modules/*.ex(user modules, compiled alphabetically)~/.config/minga/config.exs(global config).minga.exsin the current working directory (project-local config)~/.config/minga/after.exs(post-init hook)- Extensions are started (from declarations in steps 2-4)
Extensions
Extensions are reusable, self-contained editor plugins. Each extension runs under its own supervisor, so a crash in one extension never affects others or the editor.
Writing an extension
An extension is a directory containing .ex files, with one module that implements the Minga.Extension behaviour:
# ~/code/minga_todo/extension.ex
defmodule MingaTodo do
use Minga.Extension
# Declare typed config options (validated at load time)
option :keyword, :string,
default: "TODO",
description: "The keyword to scan for"
option :highlight, :boolean,
default: true,
description: "Highlight TODO lines in the gutter"
@impl true
def name, do: :minga_todo
@impl true
def description, do: "TODO tracking and highlighting"
@impl true
def version, do: "0.1.0"
@impl true
def init(config) do
keyword = Keyword.get(config, :keyword, "TODO")
{:ok, %{keyword: keyword}}
end
endOptions declared with option/3 are validated against their type when the extension loads. Users configure them directly in the extension declaration (see Loading extensions below). At runtime, read them with Minga.Config.Options.get_extension_option(:minga_todo, :keyword).
The use Minga.Extension macro also gives you a default child_spec/1 that starts a simple Agent holding your config. Override child_spec/1 if your extension needs a GenServer or a full supervision tree:
defmodule MingaTodo do
use Minga.Extension
# ... name, description, version, init callbacks ...
@impl true
def child_spec(config) do
%{
id: __MODULE__,
start: {MingaTodo.Server, :start_link, [config]},
restart: :permanent,
type: :worker
}
end
endExtensions can register commands, keybindings, and hooks using the standard config API:
# Inside your extension's init/1 or a supporting module
def init(config) do
Minga.Config.bind(:normal, "SPC c t", :todo_list, "List TODOs")
Minga.Config.register_command(:todo_list, "List TODO lines", fn ->
{:ok, content} = Minga.API.content()
todos = find_todos(content)
Minga.API.message(todos)
end)
{:ok, config}
endLoading extensions
Extensions can be loaded from three sources: local paths, git repositories, and Hex packages. Declare them in your config file:
# ~/.config/minga/config.exs
use Minga.Config
# Local path (for development or private extensions)
extension :minga_todo, path: "~/code/minga_todo"
# Git repository (bleeding-edge or private repos)
extension :minga_snippets, git: "https://github.com/user/minga-snippets"
# Hex package (published, versioned extensions)
extension :minga_tools, hex: "minga_tools", version: "~> 0.3"Exactly one of path:, git:, or hex: is required. Extra keyword options (everything except the source and its sub-options like branch: or version:) are passed to the extension as config. If the extension declares typed options with option/3, these values are validated at load time:
# Config options are grouped right in the extension declaration
extension :minga_org, git: "https://github.com/jsmestad/minga-org",
conceal: false,
pretty_bullets: true,
heading_bullets: ["•", "◦"]Local path extensions
Point at a directory containing .ex files. The directory is compiled at startup. This is the best option when you're developing an extension or using something that isn't published anywhere.
extension :my_ext, path: "~/code/my_ext"
extension :my_formatter, path: "~/code/my_formatter", format_cmd: "prettier --stdin"Path extensions don't have an update mechanism. You manage the directory yourself (git pull, etc.).
Git extensions
Git extensions are cloned to ~/.local/share/minga/extensions/{name}/ on first load. Subsequent startups use the cached checkout without touching the network, so the editor boots reliably even when GitHub is down.
# Track the default branch (main). Stays on last-fetched commit until you update.
extension :snippets, git: "https://github.com/user/minga-snippets"
# Track a specific branch
extension :snippets, git: "https://github.com/user/minga-snippets", branch: "develop"
# Pin to a tag or commit hash (updates are skipped for pinned extensions)
extension :snippets, git: "https://github.com/user/minga-snippets", ref: "v1.0.0"
extension :snippets, git: "https://github.com/user/minga-snippets", ref: "abc1234"Both HTTPS and SSH URLs work (git@github.com:user/repo.git).
Omitting both branch: and ref: defaults to whatever the remote's default branch is (usually main).
Hex extensions
Hex extensions are fetched and compiled via Mix.install/2, the same mechanism Livebook uses for notebook dependencies. This handles dependency resolution (including transitive deps), downloading, compilation, and code path setup.
# Latest stable release
extension :tools, hex: "minga_tools"
# Version constraint (standard Elixir/Hex semver syntax)
extension :tools, hex: "minga_tools", version: "~> 0.3"
extension :tools, hex: "minga_tools", version: ">= 1.0.0 and < 2.0.0"
extension :tools, hex: "minga_tools", version: "== 0.3.1"Omitting version: fetches the latest stable release.
All hex extensions are installed in a single Mix.install/2 call at startup. The results are cached (keyed on the dep list hash), so the second boot with the same extensions skips all network and compilation work. The cache lives at ~/.cache/mix/installs/.
Listing extensions
SPC h e l or :extensions (:ext) in command mode lists all loaded extensions with their source type:
Extensions:
minga_todo v0.1.0 [running] (path: ~/code/minga_todo)
snippets v0.2.0 [running] (git: https://github.com/user/minga-snippets)
tools v0.3.1 [running] (hex: minga_tools)Updating extensions
Extensions don't auto-update on startup. You control when updates happen.
Update all extensions: Press SPC h e u (or run :ExtUpdateAll). Minga fetches remote changes for all git extensions in the background, then shows a confirmation dialog stepping through each available update:
snippets: abc1234 → def5678 (3 commits on main) [Y/n/d] (1 of 2)The confirmation dialog supports three keys:
| Key | Action |
|---|---|
Y | Accept this update and advance to the next |
n | Skip this update and advance |
d | Show details (recent git commit log) in Messages |
q / Escape | Stop early, apply whatever you've accepted so far |
Pinned extensions (ref: "v1.0.0") are shown as "pinned, skipped" and cannot be updated.
After you confirm, accepted updates are applied in the background: git repos are fast-forwarded, extensions are recompiled and restarted. Results appear in *Messages* (SPC b m).
Update a single extension: Press SPC h e U (or run :ExtUpdate). A picker opens listing all extensions. Select one to check and update just that extension.
Hex extensions: Hex packages are cached by Mix.install/2. To pick up version changes, update the version constraint in your config and run SPC h r (config reload), which calls Mix.install/2 with force: true to re-resolve and recompile.
Rollback on failure
If an extension fails to compile after a git update, Minga automatically rolls back to the previous commit using the git reflog. The error is reported in *Messages* and the extension stays at its last working version. Other extensions continue updating normally.
Crash isolation
Each extension runs under its own supervisor. If an extension process crashes, it restarts automatically without affecting the editor or other extensions. If an extension fails to load (bad path, compile error, init failure), you get a clear error message and everything else keeps working.
Extension lifecycle
- Extensions are compiled and started after all config files are evaluated
SPC h rstops all extensions, reloads config, then restarts them- Extension state is lost on reload (the process restarts fresh)
- Git extensions cache at
~/.local/share/minga/extensions/ - Hex extensions cache at
~/.cache/mix/installs/
Full example
use Minga.Config
# ── Options ──────────────────────────────────────────────────────────
set :tab_width, 2
set :line_numbers, :relative
set :scroll_margin, 5
set :autopair, true
set :theme, :catppuccin_mocha
set :whichkey_layout, :float # centered floating window (or :bottom)
# Font (GUI backend only; no effect in TUI mode)
set :font_family, "JetBrains Mono"
set :font_size, 14
set :font_weight, :regular
set :font_ligatures, true
# ── Agent ─────────────────────────────────────────────────────────────
set :startup_view, :agent # boot into agentic view (default)
set :agent_auto_context, true # load CLI file as preview context (default)
set :agent_tool_approval, :destructive
set :agent_destructive_tools, ["write_file", "edit_file", "shell"]
# ── Per-language ─────────────────────────────────────────────────────
for_filetype :elixir, format_on_save: true, trim_trailing_whitespace: true, insert_final_newline: true
for_filetype :go, tab_width: 8, indent_with: :tabs, format_on_save: true
for_filetype :python, tab_width: 4, format_on_save: true
for_filetype :ruby, tab_width: 2
# ── Keybindings ──────────────────────────────────────────────────────
bind :normal, "SPC g s", :git_status, "Git status"
bind :normal, "SPC c t", :count_todos, "Count TODOs"
# ── Commands ─────────────────────────────────────────────────────────
command :git_status, "Show git status" do
{output, _} = System.cmd("git", ["status", "--short"])
Minga.API.message(output)
end
command :count_todos, "Count TODOs in buffer" do
content = Minga.API.content()
count = content |> String.split("\n") |> Enum.count(&String.contains?(&1, "TODO"))
Minga.API.message("#{count} TODOs found")
end
# ── Hooks ────────────────────────────────────────────────────────────
on :after_save, fn _buf, path ->
if String.ends_with?(path, ".ex"), do: System.cmd("mix", ["format", path])
end
# ── Command advice ───────────────────────────────────────────────────
# Skip formatting if the buffer has errors
advise :around, :format_buffer, fn execute, state ->
errors =
state.buffers.active
|> Minga.Diagnostics.for_buffer()
|> Enum.count(fn d -> d.severity == :error end)
if errors == 0, do: execute.(state), else: state
end
# ── Extensions ───────────────────────────────────────────────────────
extension :minga_todo, path: "~/code/minga_todo"
extension :snippets, git: "https://github.com/user/minga-snippets", branch: "main"
extension :tools, hex: "minga_tools", version: "~> 0.3"Further reading
Minga.Config: the DSL module (set,bind,command,on,advise,for_filetype)Minga.Config.Options: typed option registry with per-filetype overridesMinga.Config.Loader: config file discovery and evaluationMinga.Config.Hooks: lifecycle hook registryMinga.Config.Advice: before/after command adviceMinga.Keymap.Store: mutable keymap (defaults + user overrides)Minga.Keymap.KeyParser: key sequence string parserMinga.API: user-friendly editor API for commands and evalMinga.Formatter: formatter execution and default formatter registryMinga.Extension: extension behaviour and lifecycleMinga.Extension.Supervisor: extension process managementMinga.Extension.Git: git clone, fetch, update, and rollbackMinga.Extension.Hex: Hex package resolution via Mix.installMinga.Extension.Updater: update orchestration with confirmation and rollback- Elixir is Minga's Elisp: deep dive on how the BEAM enables Emacs-level extensibility