Raxol.UI.Rendering.PaintAuthority.ModeSelect (Raxol v2.6.1)

View Source

The degradation ladder's startup mode-pick decision: caps + env -> which PaintAuthority profile a session renders through.

This is a PURE function. It never calls System.get_env/1 or does any I/O itself -- callers (the assembled harness's assembler, or a mix run entry point) are responsible for gathering the env map and passing it in, exactly the same discipline Raxol.Terminal.Capabilities.Classifier.classify/3 already uses for its own env seed (env sniffing is only ever a free first-pass seed). Keeping the decision pure is what makes the full mode-pick matrix table-testable without a pty or a real tmux session.

The three tiers

  • :inline_log — the default: InlineAuthority (the append path + the footer viewport), full DECSTBM-pinned footer + scrolling history.
  • :tmux_conservative — NOT a separate authority module. The tmux tier assumes no OSC marks are consumed, clamps caps, and possibly uses a transient-region algorithm; the capability ladder already clamps %Capabilities{} for a detected multiplexer before it ever reaches InlineAuthority.new/5 (reflow_capable?/1 is conservatively false for anything that isn't measured, and tmux is never on that allowlist); ModeSelect only picks the TIER NAME so a caller knows to route through that same InlineAuthority with the already-clamped capability record it fetched from Capabilities. There is no TmuxConservativeAuthority module and this unit does not add one.
  • :flatFlatAuthority (this unit, sibling module): append-only, zero regions, zero cursor jumps. The screen-reader answer, the CI/pipe answer, the block-hater answer.

Rule order

Mode-pick happens in two passes: first a CANDIDATE mode is resolved (from an explicit override or from auto-detection), then a degenerate-geometry FLOOR is applied to whatever that candidate is. The floor runs LAST, after the candidate is chosen, not as one more item in the override/auto-detect priority list -- see "Why the degenerate floor applies after override resolution" below for why that distinction is load-bearing.

Pass 1: resolve a candidate mode

  1. Explicit env override (RAXOL_HARNESS_MODE=flat|tmux|inline, case- and whitespace-insensitive) is the candidate, when recognized. An unrecognized non-empty value does NOT win -- it falls through to auto-detection below, and is surfaced separately via select_with_reason/3's :override_unrecognized reason so a caller can warn instead of silently guessing what the operator meant.
  2. Otherwise, auto-detect: a. Headless (TERM=dumb, not a tty, or CI truthy AND not a tty) -> :flat. This has to run before the tmux check: a CI runner piping output through TERM=dumb (or no tty at all) is not a place any cursor-positioning tier belongs, tmux-flavored or not. b. tmux/screen multiplexer detected -> :tmux_conservative. c. Otherwise -> :inline_log.

Pass 2: the degenerate-geometry floor

Degenerate geometry (ScrollRegionManager.degenerate?/2: the terminal is too short to hold a footer plus a 1-row history region) clamps ANY non-:flat candidate down to :flat, regardless of whether that candidate came from an override or from auto-detection. A candidate that is already :flat (override or auto-detected) is left alone -- the floor is a one-way clamp, never a way to escape :flat.

Why the degenerate floor applies after override resolution

This ordering is load-bearing, not cosmetic: a degenerate geometry (too few rows to hold a footer plus a 1-row history region, e.g. rows: 2, footer_rows: 2) means ScrollRegionManager cannot carve out a real history region, so region_top pins at row 1. Every subsequent InlineAuthority seal then issues its append CUP to that same pinned row 1 BEFORE the terminal has scrolled -- each new block overwrites the previous one instead of accumulating (traced on real bytes: L1... then M1..., with L1 clobbered, never reaching scrollback).

That clobber has nothing to do with WHY InlineAuthority was picked -- it fires identically whether the route there was auto-detected tmux (this unit's original regression: a tmux-detected session at degenerate geometry must not fall through to :tmux_conservative, since that tier still reaches the same clobbering InlineAuthority append path) or an operator-supplied RAXOL_HARNESS_MODE=inline/=tmux override picked the same authority explicitly. An override that special-cased itself to skip the floor would reproduce the EXACT byte-traced clobber above, just reached via an exported env var instead of auto-detection -- there is no additional safety purchased by letting an override outrank the floor, only a broken transcript once the geometry turns out to be degenerate at runtime (which the operator setting the override at shell-startup time cannot always know in advance). :flat sidesteps the whole failure mode: it never positions a cursor, so there is no pinned row to clobber, and every sealed line survives in order. That is strictly safer than any cursor-positioning tier at degenerate geometry, for a candidate reached by ANY path, which is why the floor applies uniformly AFTER candidate resolution rather than being folded into the override-vs-auto-detect priority order. A :flat override is always honored -- it is already the floor's own target, so clamping is a no-op -- and a session with ADEQUATE geometry has no pinning problem and keeps whatever candidate it resolved to (override or auto-detected) as before.

Summary

Types

Pre-gathered environment facts. String keys mirror OS env var names (System.get_env/0's shape) verbatim so callers can pass that map through with no translation. :tty? is the one non-OS-env key: real tty detection is an I/O call, not a pure fact, so it is the caller's job to determine it (e.g. via :io.columns/0/:file.isatty or a driver-level flag) and thread the answer in here.

Which PaintAuthority tier a session should render through.

Why select_with_reason/3 picked the mode it returned

Functions

Picks the render mode. opts carries geometry for the degenerate- terminal check

Same mode-pick as select/3, plus the reason() the pick came from (see the reason/0 typedoc). This is the seam the assembled harness's assembler uses to print a startup notice ("routing through :flat because geometry is too small for a footer" and similar).

Types

env()

@type env() :: %{
  optional(String.t()) => String.t() | nil,
  optional(:tty?) => boolean()
}

Pre-gathered environment facts. String keys mirror OS env var names (System.get_env/0's shape) verbatim so callers can pass that map through with no translation. :tty? is the one non-OS-env key: real tty detection is an I/O call, not a pure fact, so it is the caller's job to determine it (e.g. via :io.columns/0/:file.isatty or a driver-level flag) and thread the answer in here.

mode()

@type mode() :: :inline_log | :tmux_conservative | :flat

Which PaintAuthority tier a session should render through.

reason()

@type reason() ::
  :override
  | :override_unrecognized
  | :headless
  | :tmux
  | :default
  | :degenerate_clamp

Why select_with_reason/3 picked the mode it returned:

  • :override — a recognized RAXOL_HARNESS_MODE value was honored as-is (or was already :flat, so the degenerate floor was a no-op).
  • :override_unrecognizedRAXOL_HARNESS_MODE was set to a non-empty value that isn't flat/tmux/inline (after trim+downcase); the mode fell through to auto-detection.
  • :headless — auto-detected via TERM=dumb / non-tty / CI-without-tty.
  • :tmux — auto-detected via a TMUX/screen-prefixed TERM env var or a Capabilities.multiplexer of :tmux/:screen.
  • :default — auto-detection fell through to the :inline_log default.
  • :degenerate_clamp — the degenerate-geometry floor overrode whatever candidate the above resolved to (see moduledoc).

Functions

select(caps, env, opts \\ [])

@spec select(Raxol.Terminal.Capabilities.t() | nil, env(), keyword()) :: mode()

Picks the render mode. opts carries geometry for the degenerate- terminal check:

  • :rows — total terminal rows (integer). Omit when geometry is unknown (e.g. before the first resize event) — the degenerate check is then skipped (treated as non-degenerate), matching every other rule's fail-open-to-:inline_log default.
  • :footer_rows — the footer row count the caller intends to pin (N in the H - N split). Defaults to 0. A negative or non-integer value is also treated as fail-open-to-non-degenerate rather than raised — ScrollRegionManager.degenerate?/2 itself guards footer_rows >= 0, so this module has to guard the same thing before delegating, or a caller-supplied footer_rows: -1 would crash mode-pick instead of degrading gracefully.

Never consults System.get_env/1, :persistent_term, or any device — purely a function of its three arguments. Delegates to select_with_reason/3 and discards the reason; use that function directly when the reason is needed (e.g. a startup notice explaining WHY a session ended up in :flat).

select_with_reason(caps, env, opts \\ [])

@spec select_with_reason(Raxol.Terminal.Capabilities.t() | nil, env(), keyword()) ::
  {mode(), reason()}

Same mode-pick as select/3, plus the reason() the pick came from (see the reason/0 typedoc). This is the seam the assembled harness's assembler uses to print a startup notice ("routing through :flat because geometry is too small for a footer" and similar).