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, 8

That'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:

OptionTypeDefaultDescription
:tab_widthpositive integer2Number of spaces per indent level
:line_numbers:hybrid, :absolute, :relative, :none:hybridLine number display style
:autopairbooleantrueAuto-insert matching brackets and quotes
:scroll_marginnon-negative integer5Lines to keep visible above/below cursor when scrolling
:themetheme name atom:doom_oneColor theme (see Themes below)
:indent_with:spaces or :tabs:spacesWhether to indent with spaces or tab characters
:trim_trailing_whitespacebooleanfalseStrip trailing whitespace on save
:insert_final_newlinebooleanfalseEnsure file ends with a newline on save
:format_on_savebooleanfalseRun the filetype's formatter before saving
:formatterstring or nilnilOverride the default formatter command (see Formatters)
:title_formatstring"{filename} {dirty}({directory}) - Minga"Terminal window title format (see Window Title)
:recent_files_limitpositive integer200Max recent files tracked per project
:persist_recent_filesbooleantrueWrite recent file history to disk (see Projects)
:agent_tool_approval:destructive, :all, :none:destructiveWhen to prompt before executing agent tools
:agent_destructive_toolslist of strings["write_file", "edit_file", "shell"]Which tools are classified as destructive
:agent_session_retention_dayspositive integer30Days to keep saved agent sessions before auto-pruning
:startup_view:agent or :editor:agentWhich view to show on startup (see Startup view below)
:agent_auto_contextbooleantrueLoad CLI file as agent preview context on startup
:whichkey_layout:bottom or :float:bottomWhich-key popup display mode (see Which-key popup below)
:font_familystring"Menlo"Font family or name (see Fonts below)
:font_sizepositive integer13Font size in points (see Fonts below)
:font_weightweight atom:regularFont weight (see Fonts below)
:font_ligaturesbooleantrueEnable programming ligatures (see Fonts below)
:log_level:debug, :info, :warning, :error, :none:infoGlobal minimum log level (see Logging below)
:log_level_renderlog level or :default:defaultLog level for the render pipeline
:log_level_lsplog level or :default:defaultLog level for LSP client communication
:log_level_agentlog level or :default:defaultLog level for AI agent providers
:log_level_editorlog level or :default:defaultLog 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, true

Invalid 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, :none

Customizing 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, :editor

This 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, false

The 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

ConfigCLIResult
startup_view: :agent (default)mingaAgentic view, empty
startup_view: :agent (default)minga foo.exAgentic view, file in preview
startup_view: :agent, agent_auto_context: falseminga foo.exAgentic view, preview empty, file in buffer list
startup_view: :agentminga --no-context foo.exSame as above
startup_view: :agentminga --editor foo.exEditor view, file open
startup_view: :editorminga foo.exEditor view, file open
startup_view: :editormingaEditor 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   # default

Set it to :float to render the which-key popup as a centered floating window with a rounded border and title:

set :whichkey_layout, :float

The 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
ThemeStyle
:doom_oneDark (default), Doom Emacs
:catppuccin_frappeDark, Catppuccin family
:catppuccin_latteLight, Catppuccin family
:catppuccin_macchiatoDark, Catppuccin family
:catppuccin_mochaDark, Catppuccin family
:one_darkDark, Atom
:one_lightLight, 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, true

Font 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 regular

Not 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 individually

Ligatures only work when two conditions are met:

  1. :font_ligatures is true
  2. 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, :info

Each 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, :debug

The 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

SubsystemOptionWhat it covers
render:log_level_renderRender pipeline stage timing and frame composition
lsp:log_level_lspLSP server communication, requests, and errors
agent:log_level_agentAI agent providers, sessions, and tool execution
editor:log_level_editorGeneral 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 details

Open *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:

PlaceholderExpands 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: true

When 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:

LanguageDefault formatter
Elixirmix format --stdin-filename {file} -
Gogofmt
Rustrustfmt --edition 2021
Pythonpython3 -m black --quiet -
JavaScript/TypeScriptprettier --stdin-filepath {file}
Zigzig 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: true

When 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: true

These 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

LanguageExtensions
Bash.sh, .bash, .zsh
C.c, .h
C#.cs, .csx
C++.cpp, .cc, .cxx, .hpp
CSS.css
Dart.dart
Diff.diff, .patch
DockerfileDockerfile
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
MakeMakefile, .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.scm

For example, to customize Elixir highlighting:

~/.config/minga/queries/elixir/highlights.scm

Your 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.

  1. 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.

  1. 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
  1. Reload. Press SPC h r to 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.pipe captures in the query and sends them to the BEAM.
  • The face registry resolves "operator.pipe" by walking the inheritance chain: operator.pipeoperatordefault. 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:

  1. Vendor the grammar: copy the tree-sitter grammar's src/ directory into zig/vendor/grammars/{lang}/src/. You need parser.c and optionally scanner.c.
  2. Add a highlight query: place a highlights.scm at zig/src/queries/{lang}/highlights.scm. The grammar's upstream repo usually has one you can start from.
  3. Register in the Zig build: add an entry to the grammars array in zig/build.zig with has_scanner set appropriately.
  4. Register in the highlighter: in zig/src/highlighter.zig, add an extern fn tree_sitter_{lang}() declaration and an entry in the languages array.
  5. Register the filetype: add extension/filename mappings in lib/minga/filetype.ex so 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, true

This 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:

SourceDisplayLanguages
->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:

TokenKey
SPCSpace
TABTab
RETReturn/Enter
ESCEscape
C-xCtrl + x
M-xAlt/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"
end

You can also use the explicit filetype: option for one-off bindings:

bind :normal, "SPC m p", :markdown_preview, "Preview", filetype: :markdown

Different 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}")
end

Supported events

EventArgumentsFires 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:

PhaseSignatureBehavior
:beforefn state -> state endTransforms state before the command
:afterfn state -> state endTransforms state after the command
:aroundfn execute, state -> state endReceives the original command; decides whether/how to call it
:overridefn state -> state endCompletely 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
end

Multiple 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
end

Then 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])
end

This 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:

  1. Stops all running extensions
  2. Purges user modules
  3. Resets options, keybindings, hooks, advice, and commands to defaults
  4. Re-compiles modules, re-evaluates config.exs, .minga.exs, and after.exs
  5. 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:

  1. ~/.config/minga/modules/*.ex (user modules, compiled alphabetically)
  2. ~/.config/minga/config.exs (global config)
  3. .minga.exs in the current working directory (project-local config)
  4. ~/.config/minga/after.exs (post-init hook)
  5. 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
end

Options 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
end

Extensions 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}
end

Loading 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:

KeyAction
YAccept this update and advance to the next
nSkip this update and advance
dShow details (recent git commit log) in Messages
q / EscapeStop 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 r stops 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