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.
Summary
Functions
Returns true when a modal is currently active (modal != :none).
Closes the active modal cleanly.
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.
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.
Dismisses the active modal as a cancellation.
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.
Returns true when modal matches the given variant tag.
Returns the closed modal (:none).
Opens a modal overlay, replacing any active one.
Updates the completion trigger.
Returns the variant tag of modal, or :none when no modal is active.
Transitions the active modal to a new variant unconditionally.
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.
Types
@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()}
@type variant() :: :picker | :prompt | :completion | :conflict | :dashboard
Functions
Returns true when a modal is currently active (modal != :none).
@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.
@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.
@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.
@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.
@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.
Returns true when modal matches the given variant tag.
match(:none, :none) is true; match({:picker, _}, :picker) is true.
Any other combination is false.
@spec none() :: t()
Returns the closed modal (:none).
@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.
@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.:noneand trigger has pending activity (debounce timer or pending request) — open a new completion modal withcompletion: niland the given trigger so the bridge state has somewhere to live until the LSP response arrives.:noneand trigger is empty — no-op.- any other variant — no-op (don't displace another modal for a backend bookkeeping update).
Returns the variant tag of modal, or :none when no modal is active.
@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).
@spec update_completion(MingaEditor.State.t(), (Minga.Editing.Completion.t() -> 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.