# `MingaEditor.State.ModalOverlay`
[🔗](https://github.com/jsmestad/minga/blob/main/lib/minga_editor/state/modal_overlay.ex#L1)

Tagged-union representation of input-capturing modal overlays.

Before this work, the picker, prompt, completion menu, conflict prompt,
and dashboard each lived as an independent nullable field on shell
state or workspace state. The type system permitted 32 combinations
even though only six were meaningful, and `MingaEditor.Input.Interrupt`
reset eight independent axes by hand. See `docs/UI-STATE-ANALYSIS.md`
for the full analysis.

This module replaces those fields with a single sum type:

    :none
    | {:picker, ModalOverlay.Picker.t()}
    | {:prompt, ModalOverlay.Prompt.t()}
    | {:completion, ModalOverlay.Completion.t()}
    | {:conflict, ModalOverlay.Conflict.t()}
    | {:dashboard, ModalOverlay.Dashboard.t()}

## Migration status (#1421)

All five variants are now tracked exclusively on `state.shell_state.modal`.
The legacy nullable fields (`shell_state.dashboard`, `shell_state.prompt_ui`,
`workspace.pending_conflict`, `workspace.completion`,
`workspace.completion_trigger`) have all been removed. #1427 finalises by
collapsing `Input.Interrupt` and adding a Credo enforcement rule.

**Do not mutate `:modal` directly**: always call this module's
`open/3`, `transition/3`, `close/1`, `dismiss/1`, `update_completion/2`,
or `update_completion_trigger/2`.

## Replacement policy

`open/3` while a modal is already active replaces the previous one. There
is no queue. The exception is `:conflict`: while a conflict prompt is
active, other `open/3` calls are logged and return state unchanged. This
matches the original behaviour where `ConflictPrompt` sat before every
other handler in the input stack.

`transition/3` performs the same replacement but without the sticky
rule; it is intended for FSM-style steps where the caller has already
decided that a transition must happen (e.g., picker → prompt).

## Per-tab semantics for completion

Completion is logically per-tab — it tracks the cursor of the buffer
that triggered it. The `Completion` payload carries an `owner` tab id;
`dismiss_if_stale/1` runs from `EditorState.switch_tab/2` after the new
context is restored, dismissing completion that no longer belongs to
the active tab. Other variants live on shell state, which isn't
snapshotted per tab, so they don't need this hook.

# `payload`

```elixir
@type payload() ::
  MingaEditor.State.ModalOverlay.Picker.t()
  | MingaEditor.State.ModalOverlay.Prompt.t()
  | MingaEditor.State.ModalOverlay.Completion.t()
  | MingaEditor.State.ModalOverlay.Conflict.t()
  | MingaEditor.State.ModalOverlay.Dashboard.t()
```

# `t`

```elixir
@type t() ::
  :none
  | {:picker, MingaEditor.State.ModalOverlay.Picker.t()}
  | {:prompt, MingaEditor.State.ModalOverlay.Prompt.t()}
  | {:completion, MingaEditor.State.ModalOverlay.Completion.t()}
  | {:conflict, MingaEditor.State.ModalOverlay.Conflict.t()}
  | {:dashboard, MingaEditor.State.ModalOverlay.Dashboard.t()}
```

# `variant`

```elixir
@type variant() :: :picker | :prompt | :completion | :conflict | :dashboard
```

# `active?`

```elixir
@spec active?(t()) :: boolean()
```

Returns true when a modal is currently active (`modal != :none`).

# `close`

```elixir
@spec close(MingaEditor.State.t()) :: MingaEditor.State.t()
```

Closes the active modal cleanly.

Used when the modal has completed its task (e.g., picker accepted,
prompt submitted). No-op when no modal is active.

# `completion`

```elixir
@spec completion(map()) :: Minga.Editing.Completion.t() | nil
```

Returns the active `Completion.t()` when the modal is `{:completion, _}`,
otherwise `nil`. Read site for chrome rendering, render-pipeline input
build, and any code path that historically read `workspace.completion`.

Accepts any struct or map that carries `shell_state.modal` so the
RenderPipeline.Input flavour of state works the same as EditorState.

# `completion_trigger`

```elixir
@spec completion_trigger(map()) :: MingaEditor.CompletionTrigger.t()
```

Returns the active `CompletionTrigger.t()` from the completion payload,
or a fresh trigger when no completion modal is active. Callers that just
want to consult the trigger lifecycle (debounce, pending refs) without
caring about completion state itself use this.

# `dismiss`

```elixir
@spec dismiss(MingaEditor.State.t()) :: MingaEditor.State.t()
```

Dismisses the active modal as a cancellation.

Used when the user backs out (Esc, Ctrl-G). Identical to `close/1` —
the distinction exists so callers can express intent and future
per-variant cleanup hooks can branch on it.

# `dismiss_if_stale`

```elixir
@spec dismiss_if_stale(MingaEditor.State.t()) :: MingaEditor.State.t()
```

Dismisses the active completion modal when its `owner` no longer
matches the now-active tab. Called from `EditorState.switch_tab/2` after
the target tab's context is restored.

Only completion has per-tab semantics; other modals live on shell state
and don't snapshot per tab, so this is a no-op for them.

# `match`

```elixir
@spec match(t(), :none | variant()) :: boolean()
```

Returns true when `modal` matches the given variant tag.

`match(:none, :none)` is true; `match({:picker, _}, :picker)` is true.
Any other combination is false.

# `none`

```elixir
@spec none() :: t()
```

Returns the closed modal (`:none`).

# `open`

```elixir
@spec open(MingaEditor.State.t(), variant(), payload()) :: MingaEditor.State.t()
```

Opens a modal overlay, replacing any active one.

Conflict prompts are sticky: if the active modal is a conflict, calls
to `open/3` for any other variant are logged and return state
unchanged. To force a transition out of a conflict modal, call
`close/1` or `dismiss/1` first, or use `transition/3`.

# `put_completion_trigger`

```elixir
@spec put_completion_trigger(MingaEditor.State.t(), MingaEditor.CompletionTrigger.t()) ::
  MingaEditor.State.t()
```

Updates the completion trigger.

Behaviour by current modal:
- `:completion` — update the payload's trigger in place.
- `:none` and trigger has pending activity (debounce timer or pending
  request) — open a new completion modal with `completion: nil` and the
  given trigger so the bridge state has somewhere to live until the LSP
  response arrives.
- `:none` and trigger is empty — no-op.
- any other variant — no-op (don't displace another modal for a backend
  bookkeeping update).

# `tag`

```elixir
@spec tag(t()) :: :none | variant()
```

Returns the variant tag of `modal`, or `:none` when no modal is active.

# `transition`

```elixir
@spec transition(MingaEditor.State.t(), variant(), payload()) :: MingaEditor.State.t()
```

Transitions the active modal to a new variant unconditionally.

Equivalent to `open/3` except the conflict-sticky rule is bypassed.
Use this when the caller has already decided the transition must
happen (e.g., dismissing a picker into a prompt).

# `update_completion`

```elixir
@spec update_completion(MingaEditor.State.t(), (Minga.Editing.Completion.t() -&gt;
                                            Minga.Editing.Completion.t())) ::
  MingaEditor.State.t()
```

Updates the inner `Completion.t()` of the active completion modal via
`fun`. No-op when no completion modal is active. Use for menu-navigation
events (move_up/move_down) that mutate `Completion` without changing
the gate's variant.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
