Minga.Keymap.Scope behaviour (Minga v0.1.0)

Copy Markdown View Source

Behaviour and resolution logic for buffer-type-specific keybindings.

A keymap scope determines which keybindings are active based on the type of view the user is interacting with. Think of scopes as Neovim's which-key groups: flat, explicit declarations of what keys do in a given context.

Design rules

The keymap follows Neovim's flat model, not Emacs's composed hierarchy. See AGENTS.md § "Keymap Architecture" for the full rationale. Three rules:

  1. Keymap is the single authority. If a key resolves through a scope trie, the command runs. Commands never re-check context internally. Don't bind a command in a scope where it shouldn't run.

  2. Scopes are flat. No implicit inheritance or minor-mode stacking. Shared bindings come in through bulk registration helpers that merge named binding groups at compile time. See #1278.

  3. Derived scope. The active scope should follow from what's on screen, not from a manually managed field. (Target architecture; today workspace.keymap_scope is still a field.)

Built-in scopes

  • :editor — normal text editing (default)
  • :agent — agent chat view (Board zoom or side panel)
  • :file_tree — file tree panel
  • :git_status — git status panel

Resolution layers

Each scope module implements this behaviour and declares its own keybindings as trie data. Keymap resolution walks layers in priority order:

  1. User overrides for the active scope + vim state
  2. Vim-state-specific bindings from the scope module
  3. Shared bindings that apply across all vim states for the scope
  4. :not_found (caller decides what to do: self-insert, passthrough, etc.)

Global bindings (leader sequences, Ctrl+S) and the Mode FSM fallback are handled by the caller, not by this module.

Context parameter

The keymap/2 callback receives a context keyword list. Phase 1 always passes []. Phase 2 (#215) will pass [filetype: :elixir] so the editor scope can return filetype-specific bindings. Agent and file_tree scopes ignore context.

Summary

Types

Extra context for scope keymap resolution (e.g., filetype).

A single help binding: {key_string, description}.

A group of help bindings with a category label.

Result of resolving a key through the scope system.

A scope name atom.

Vim state relevant to scope resolution.

Callbacks

Returns a human-readable name for display (e.g., "Agent").

Returns categorized help groups for the ? help overlay.

Returns the shared binding groups this scope includes.

Returns the keybinding trie for a specific vim state.

Returns the atom name of this scope (e.g., :agent).

Called when this scope becomes active. Initialize scope-specific state.

Called when this scope is deactivated. Clean up scope-specific state.

Returns bindings that apply regardless of vim state.

Functions

Returns all registered scope names.

Returns help groups for the given scope and focus context.

Returns the scope module for a given scope name.

Resolves a key through the scope's keybinding layers.

Resolves a key against a specific trie node (for multi-key sequences).

Types

context()

@type context() :: keyword()

Extra context for scope keymap resolution (e.g., filetype).

help_binding()

@type help_binding() :: {String.t(), String.t()}

A single help binding: {key_string, description}.

help_group()

@type help_group() :: {String.t(), [help_binding()]}

A group of help bindings with a category label.

resolve_result()

@type resolve_result() ::
  {:command, atom()} | {:prefix, Minga.Keymap.Bindings.node_t()} | :not_found

Result of resolving a key through the scope system.

  • {:command, atom()} — execute this named command
  • {:prefix, Bindings.node_t()} — key is a prefix; more keys needed
  • :not_found — scope has no binding for this key

scope_name()

@type scope_name() :: :editor | :agent | :file_tree | :git_status

A scope name atom.

vim_state()

@type vim_state() :: :normal | :insert | :input_normal | :cua

Vim state relevant to scope resolution.

Callbacks

display_name()

@callback display_name() :: String.t()

Returns a human-readable name for display (e.g., "Agent").

help_groups(focus)

@callback help_groups(focus :: atom()) :: [help_group()]

Returns categorized help groups for the ? help overlay.

Each group is a {category_label, [{key_string, description}]} tuple. The focus parameter lets scopes return different help content depending on the current UI context (e.g., :chat vs :file_viewer in the agent scope).

Return [] to indicate no help overlay is available for this scope.

included_groups()

(optional)
@callback included_groups() :: [atom() | {atom(), keyword()}]

Returns the shared binding groups this scope includes.

Each entry is either a group name atom or a {group_name, opts} tuple with exclusion options. Used for introspection and documentation; the actual merge happens in the scope's trie-building functions.

Optional callback. Returns [] by default.

keymap(vim_state, context)

@callback keymap(vim_state(), context()) :: Minga.Keymap.Bindings.node_t()

Returns the keybinding trie for a specific vim state.

The context parameter is a keyword list that phase 1 passes as []. Phase 2 will pass [filetype: :elixir] for filetype-specific bindings.

name()

@callback name() :: scope_name()

Returns the atom name of this scope (e.g., :agent).

on_enter(state)

@callback on_enter(state :: term()) :: term()

Called when this scope becomes active. Initialize scope-specific state.

on_exit(state)

@callback on_exit(state :: term()) :: term()

Called when this scope is deactivated. Clean up scope-specific state.

shared_keymap()

@callback shared_keymap() :: Minga.Keymap.Bindings.node_t()

Returns bindings that apply regardless of vim state.

These are checked after vim-state-specific bindings but before global bindings. Useful for keys like Ctrl+C (abort) that work the same in both normal and insert mode within a scope.

Functions

all_scopes()

@spec all_scopes() :: [scope_name()]

Returns all registered scope names.

help_groups(scope_name, focus \\ :default)

@spec help_groups(scope_name(), atom()) :: [help_group()]

Returns help groups for the given scope and focus context.

Delegates to the scope module's help_groups/1 callback. Returns [] if the scope is not found.

module_for(name)

@spec module_for(scope_name()) :: module() | nil

Returns the scope module for a given scope name.

resolve_key(scope_name, vim_state, key, context \\ [])

Resolves a key through the scope's keybinding layers.

Walks layers in priority order:

  1. User overrides for the scope + vim state (from Keymap.Active)
  2. Vim-state-specific bindings for the active scope
  3. Shared bindings for the active scope
  4. Returns :not_found if no scope binding matches

Global bindings (leader sequences, Ctrl+S) and Mode.process fallback are handled by the caller, not by this function.

resolve_key_in_node(node, key)

Resolves a key against a specific trie node (for multi-key sequences).

Used when continuing a prefix sequence within a scope. The caller tracks which trie node to continue from.