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

View Source

The real (production) PaintAuthority implementation: the printed- history append path.

This is the module that fills in what IOAuthority deliberately leaves as a stub — it composes:

  • Raxol.Terminal.ScrollRegionManager for the DECSTBM region/footer split (history_bottom/1, resize/2 re-set the region exactly once, never a full clear).
  • The inline driver's device seam — the output sink is a parameter (IO.device()), the same :device the scroll-region manager and InlineDriver already thread through, so a StringIO pid (or ExUnit.CaptureIO) captures bytes with no pty in tests and :stdio writes for real in production.
  • The shared Dialect wire vocabulary (cursor_save/0, cursor_restore/0) the byte-capture oracle (Raxol.Harness.Test.SealOracle) already parses.

Seal-once, by construction: fill down, then scroll

A fresh history region is empty capacity, not yet "full" — real terminal output naturally advances line by line from wherever the cursor last was, only falling back to scroll-at-the-boundary once the bottom row is reached. This module models that explicitly with a next_row cursor tracked in its own state: append_sealed/2 positions at min(next_row, history_bottom) (the next unfilled history row, clamped to the region's bottom once full), writes, and advances next_row by the number of lines written (also clamped). Once next_row reaches history_bottom, every subsequent append targets that same bottom row and relies on the terminal's own index-at-region-boundary semantics to scroll — the row is never re-addressed with different content, only ever pushed one row closer to eviction into scrollback.

This isn't a style choice: it is what makes the seal-once invariant (Raxol.Harness.Test.SealOracle.immutable_prefix?/2, history/3's emit-derived high-water accounting) actually hold. An implementation that always targets the bottom row from the first append onward front- loads the combined scrollback ++ on-screen history with content-free filler rows ahead of any real content, which defeats the high-water prefix check silently; fill-down-then-scroll avoids this, and it is load-bearing, not a style preference.

The test in test/property/renderer_adversarial_property_test.exs ("repainting an already-sealed row...") is a separate thing: an ORACLE- VALIDITY guard that hand-injects a known-bad stream (a row-1 repaint — a different, also-invalid violation class) to prove SealOracle catches a real immutable-prefix violation instead of rubber-stamping every input. It does not reproduce, and was never meant to reproduce, the filler bug described above. A pinned seal CUPs to exactly one row: min(next_row, history_bottom) under :fill_down (the default described above), or history_bottom itself under :scroll_entry (chat semantics -- see the entry_mode typedoc; the front-loaded filler that mode admits into scrollback is its DOCUMENTED, bounded dirty-scrollback cost, and its oracle accounting offsets the fixed junk prefix instead of relying on fill-down's zero-filler property).

The cursor-ownership protocol

One owner module, both paths go through it. This module's with_cursor/3 is that owner — the SOLE place a save/restore bracket is opened. append_sealed/2 itself never saves or restores; it only positions+writes. The full protocol (save -> position -> emit -> restore) is seal/2, the composition of the two:

seal(t, iodata) == with_cursor(t, :history, fn s -> append_sealed(s, iodata) end)

Callers (the append path's own driver, and the footer viewport's own positioning) should go through with_cursor/3 for anything that moves the cursor, so saves and restores from the two emit vocabularies never interleave (a save inside another save's bracket silently clobbers the single hardware DECSC register — see SealOracle.save_restore_balance/1 and its _max_depth field).

\e[2J is never emitted

Neither this module nor Raxol.Terminal.ScrollRegionManager (which owns the DECSTBM re-set on resize/3) ever writes \e[2J/\e[3J: real-hardware measurement showed a full-screen clear wipes native scrollback on wezterm/kitty, which would destroy history that, once sealed, exists only as terminal-owned pixels this process can no longer reconstruct.

Resize scope: ships seal-time-only, wires the reflow-aware detection seam

This module ships seal-time-only: resize/3 NEVER re-emits previously-sealed content — the only byte it writes on resize is ScrollRegionManager's single DECSTBM re-set (content already on-screen is left exactly as it is; a shrinking region merely clamps where FUTURE appends resume, per next_row's resize clamp below). reflow_capable?/1 is the reflow-aware detection SEAM: a pure predicate over a terminal identity, reporting whether the terminal-matrix probe measured THIS terminal to reflow sealed scrollback cleanly on resize (today: iTerm2 only — wezterm/kitty were measured NOT to; ghostty is unmeasurable and conservatively false). resize/3 consults it (alongside ScrollRegionManager.geometry_changed?/2's thin "did the split point actually move" fact) purely to emit a :telemetry event when both hold — no bytes are re-emitted. A FUTURE unit reads that telemetry (or calls reflow_capable?/1 directly) to gate bounded soft-owned-history re-emission. Wiring the hook here, without acting on it, is the whole point: reflow-aware re-emission is a runtime-detected additive upgrade, deferred for a future unit to implement. Contract-only-grows: this module never has to be rewritten to add reflow-aware re-emission later, only extended.

The repaint_footer/2/keyframe_footer/2 @impl callbacks were deliberate placeholders: minimal pass-through stubs that satisfy the behaviour without any real positioning/diff logic. This module fills that in with a buffer-diff pipeline scoped to the footer rows only — every emitted CUP stays inside footer rows:

  • footer_diff/2 — pure function, no I/O: given the last-painted footer content and the next footer content (both one binary per footer row, top-to-bottom, already padded/truncated to the same row count), returns only the {row_index, line} pairs that actually changed. This is the "minimal repaint bytes" half of the pipeline, kept separate from the emit half so it is unit-testable with zero device/cursor setup.
  • repaint/2 — the diff-driven entry point for normal per-frame footer updates: pads/truncates the caller's next footer lines to the CURRENT footer row count (ScrollRegionManager.footer_range/1 via footer_row_count/1), diffs against footer_lines (the struct field this module now tracks), and emits ONLY the changed rows — each one CUP (to region_top(t) + 1 + row_index, always inside the footer range) then \e[K (clear that row, never \e[2J/\e[3J) then the new content — inside a single with_cursor/3 bracket (:footer region) so a footer repaint never corrupts the history path's saved cursor. A no-op diff (nothing changed) emits zero bytes.
  • keyframe/2 — the FULL footer repaint: every footer row cleared and rewritten regardless of what changed, still per-row \e[K (never a full-screen clear). This is the Ctrl-L recovery entry point. Composition note: resize/3 (below) re-derives the DECSTBM split but deliberately does NOT call this automatically — see resize/3's doc for why (an existing regression test on the append path pins resize to emit only the DECSTBM re-set). Callers that also need the footer re-rendered at the new geometry compose explicitly: authority |> InlineAuthority.resize(w, h) |> InlineAuthority.keyframe(current_lines). Both calls already guarantee no \e[2J/\e[3J and no history addressing, so the composition inherits both properties for free.

Every row either function addresses is computed from region_top(t) + 1 .. rows(region) (the scroll-region manager's footer_range/1) — never a hand-maintained constant — so a footer paint can never drift into the scrolling history region even under resize.

repaint/2 and keyframe/2 pad/truncate the LIST of footer lines to the current footer row COUNT (footer_row_count/1) — but neither function measures or truncates an individual LINE's display width. A line wider than the terminal's column count wraps onto the following row (a real terminal's own line-wrap behavior), which breaks footer confinement: the wrapped tail lands on whatever row follows, which may be the next footer row (silently overwriting content that only repaint/2 is assumed to address) or, on the LAST footer row, past the bottom of the screen entirely. This module does not defend against that — it is the caller's responsibility to display-width-truncate every line to the authority's width (the same value passed to new/5) BEFORE calling repaint/2/keyframe/2. Use Raxol.UI.TextMeasure for that measurement — never String.length/1, which undercounts double-width (CJK) characters and would let a line that measures "short" by codepoint count still overflow the column budget.

Degenerate geometry: degenerate?/1

A terminal too short for its requested footer (rows - footer_rows < 2, the scroll-region manager's ScrollRegionManager.degenerate?/1) cannot have its footer actually pinned via DECSTBM — see that module's moduledoc for why. This module surfaces that fact via degenerate?/1 (a thin delegation, no behavior change) so callers can detect the condition and adapt — e.g. falling back to redrawing the footer every frame — instead of silently trusting a pin that a real terminal ignored. repaint/2 and keyframe/2 still function on a degenerate geometry (never crash): they simply operate over whatever footer capacity footer_range/1 actually reports for that geometry, which may be smaller than footer_rows requested, or empty.

Two properties enforced here:

  • Footer content is not trusted either. The history append path does not write agent/LLM-originated iodata verbatim (ContentGuard.sanitize_line/1); the footer path carries the SAME kind of content (live-tail/agent text) through the SAME risk (a footer line smuggling \e[3;1H/\e[2J/etc. would execute against the footer confinement invariant exactly like an unguarded history append would against the seal-once invariant). repaint/2 and keyframe/2 both run every caller-supplied line through ContentGuard.sanitize_line/1 at entry — BEFORE padding or diffing — so footer_diff/2 and footer_lines only ever see already-neutralized content. footer_lines itself is therefore an invariant: once sanitized in, never re-sanitized out, so footer_diff/2 comparing old (already-sanitized) against new (freshly-sanitized) is always an apples-to-apples comparison.
  • A stale post-resize repaint could leave ghost content. resize/3 clamps next_row but, by design (see above), never repaints the footer — a geometry-changing resize can relocate the footer's on-screen rows to different absolute row numbers while their CONTENT (and thus footer_diff/2's logical, index-based comparison) is unchanged. A subsequent repaint/2 call with unchanged lines then computes a no-op diff and writes NOTHING, leaving those rows showing whatever was on screen at that position before the resize (leftover history text, or a stale row repaint/2 previously left blank via \e[K at the OLD position). The needs_keyframe flag closes this: a resize that changes EITHER axis — vertical geometry (history_bottom) OR width — sets it (a pure state change — the pinned regression test asserting resize's ONLY new bytes are ScrollRegionManager's single DECSTBM re-set is untouched, since setting a struct field emits no bytes, and a width-only resize re-emits no region bytes at all). Width matters here even though the region doesn't move: a reflow-capable terminal rewraps sealed history on a width change, and a width-shrink can wrap an untruncated footer line past the pin, so the footer needs a clean re-render at the new width. The NEXT repaint/2 call checks the flag FIRST and, if set, self-promotes to a full keyframe/2 (which clears the flag) instead of running its normal diff — so the first repaint after any geometry OR width change always fully re-renders the footer at its current position and width, regardless of whether the logical content changed.

set_footer_rows/2 is the seam a footer-hosted overlay uses to claim (or give back) rows from the footer viewport WITHOUT a real terminal resize -- rows/width are unchanged, only the DECSTBM split point moves, via ScrollRegionManager.set_footer_rows/2 (the resize/2 counterpart that holds rows constant and varies footer_rows instead).

A temporary overlay must never unpin the live footer: a target that would make ScrollRegionManager.degenerate?/2 true (history could not keep its 2-row minimum) is refused outright, {:error, :degenerate}, zero bytes -- the caller keeps whatever footer it already had.

Growing the footer (claiming rows FROM history) must not silently paint over content that already occupies those rows. Whatever currently fills the reclaimed range is scrolled up first, via plain " " bytes written at the OLD bottom row while the OLD (still wider) DECSTBM region is still active: each landing on that row is the same index-at-region-boundary behavior append_sealed/2 already relies on -- the region scrolls, the row evicted off the top lands in the terminal's own native scrollback, and nothing is ever re-painted. Only after that scroll does the DECSTBM split actually move (ScrollRegionManager.set_footer_rows/2) and needs_keyframe: true gets set -- the SAME latch resize/3 uses, so the next repaint/2 call self-promotes to a full keyframe/2 and redraws the (now smaller) footer cleanly at its new position.

Shrinking the footer (giving rows BACK to history) is the reverse concern: the rows being vacated are still footer-owned (about to become history) and may hold stale overlay pixels from the frame before dismissal -- history appends resume ABOVE them, so nothing else will ever clear them on its own. Each vacated row is explicitly cleared (CUP + , never ) BEFORE the DECSTBM split moves back, then the same needs_keyframe latch is set so the footer's remaining content is fully re-rendered at its new (smaller) position.

Neither direction ever emits /.

The guiding principle (see docs/PHILOSOPHY.md: "We are a guest in the user's terminal, not an occupier"): the harness must not claim the whole screen on an empty session. new/5's pin: :adaptive option starts this authority in a FLOATING state instead of pinning at boot:

  • No scroll region is set while floating — the terminal keeps its full-screen default. This is the load-bearing model choice: sealed rows are written by plain fill-down native flow (the same next_row cursor as the pinned model), so when they eventually scroll they scroll NATIVELY into the terminal's own scrollback — no DECSTBM geometry to fight, no bytes to justify on boot.
  • The footer paints directly below the last content row — at absolute rows next_row..(next_row + footer_rows - 1) (the top of the screen on boot). next_row is the single source of truth for the floating position; every footer paint site derives it through footer_top/1.
  • A floating seal first EL-clears the footer rows it converts to content (the footer is repaintable — erasing is legal; sealed content carries no EL of its own), writes the content once, and latches needs_keyframe so the frame's trailing footer paint re-renders at the new position — the footer migrates down by exactly the sealed row count.
  • The float->pin transition is ONE-WAY per session and fires the moment content reaches the pinned footer position (next_row > history_bottom after a seal, a seal too large to fit above the floating footer, a resize-shrink past the content, or a footer grow the floating window cannot host). The transition erases the floating footer (targeted EL), scrolls just enough rows into native scrollback via plain \n at the screen bottom (native flow — never a repaint) to restore the pinned append invariant, and claims the region with ONE DECSTBM write (the honest full-screen release on degenerate geometry). Not one already- emitted content byte is rewritten. From then on the authority is byte-for-byte today's pinned model.
  • While floating, resize/3/set_footer_rows/2/reassert/1 never emit region bytes — geometry updates go through ScrollRegionManager.plan/3 (the pure constructor); only the transition emits.

The default is pin: :immediate — exactly today's pinned-from-boot model, byte-identical, so every existing byte-golden suite and fixture stays valid unchanged. Demos opt into :adaptive.

GUEST-BOOT (:boot_cursor, layered on :adaptive): instead of starting the float at row 1 of a pushed-blank screen, start it at the DSR-probed row where the user's shell left the cursor — the float begins mid-screen under the prompt, or (prompt at/near the bottom, the normal shell) construction rides the SAME one-way transition into the pinned model immediately, scrolling the shell's own history up via plain \ns exactly like any program printing N lines. See new/5's :boot_cursor doc for the full contract; enforced by test/harness/guest_boot_test.exs.

Synchronized output (DEC private mode 2026)

sync_open/1 / sync_close/1 are the DEC 2026 synchronized-update bracket (Dialect.sync_begin/0 ... Dialect.sync_end/0): a Surface frame that seals one or more blocks wraps the seal writes and the trailing footer repaint in this bracket so a multi-block seal presents to the terminal atomically (no partial-frame flicker between the sealed history and the repainted footer). Gated on this authority's sync_output? field (measured once, at new/5, from the capability record, strict struct match) -- capability-unknown means don't emit, never a guess.

The pair is latch-backed (sync_close_pending?): a close the device refuses is remembered as OWED and re-attempted on every subsequent frame until a write lands, so a landed ?2026h can never dangle forever with the terminal wedged in synchronized mode -- the failure window is "until the next frame with a writable device", not "until the process exits". See sync_open/1/sync_close/1 for the full contract.

Summary

Types

How a PINNED seal enters the history region (chat semantics -- the conversation sticks to the bottom)

The footer-placement state machine (FOOTER-FOLLOWS-CONTENT)

t()

Functions

True when this authority's current geometry cannot form a valid DECSTBM region — i.e. the footer is NOT actually pinned right now (see the moduledoc's "Degenerate geometry" section). A thin delegation to ScrollRegionManager.degenerate?/1; no behavior change of its own. Callers that need to know whether the pin is real should check this rather than assuming new/5/resize/3 always succeeded.

Whether an exception is a DEVICE failure raised by the :io layer -- the discriminator scoping try_seal/2's (and the sync bracket's) rescue to the device seam and nothing else.

Erases transient rows (targeted EL per row, never \e[2J), cursor save/restore bracketed. The boot greeting's exit: called by the assembly layer immediately BEFORE the first seal's bytes, in the same frame, so the greeting vanishes exactly when real content arrives and never coexists with (or enters) sealed history.

Pure diff: only the {row_index, line} pairs that differ between old_lines and new_lines (both already the SAME length — pad/ truncate before calling, see repaint/2). No I/O, no cursor movement. row_index is 0-based, relative to the top of the footer.

The current footer row count. Pinned: the size of ScrollRegionManager.footer_range/1, never a hand-maintained constant. Floating: the requested footer_rows, clamped to the rows physically below the content (rows - next_row + 1) -- the screen-bottom clamp; at rest the floating invariant (next_row <= history_bottom) makes the clamp a no-op, but a degenerate geometry (footer taller than the screen) honestly reports only the rows that exist.

Footer keyframe: every footer row cleared (\e[K, never a full-screen clear) and rewritten, regardless of what changed. The Ctrl-L recovery entry point, and what a caller composes after resize/3 to re-derive the footer's on-screen content at the new geometry (see the moduledoc's "pinned footer viewport" section for why resize/3 itself does not call this) -- and what repaint/2 self-promotes to when needs_keyframe is set. Sanitizes every line of new_lines through ContentGuard.sanitize_line/1 (same as repaint/2), then pads/ truncates to the CURRENT footer row count.

Builds a new authority: sets the DECSTBM region via ScrollRegionManager.start/3 (one write, CSI 1;(H-N) r), records whether this session's terminal is on the reflow-aware detection allowlist, and starts the fill-down cursor (next_row) at the region's first row.

Paints one TRANSIENT line at an absolute {row, col} inside the unclaimed span (see unclaimed_span/1 -- the caller owns the placement decision; this function only refuses off-screen rows). Cursor save/restore bracketed, content routed through ContentGuard.sanitize_line/1 like every other emitted line. NO bookkeeping changes: a transient element is not history (next_row does not move), not footer (no repaint diff state), and the caller is responsible for erasing it (erase_transient/2) before sealed content can ever reach its rows -- the boot greeting's first-seal erase law.

Re-asserts the DECSTBM history/footer split UNCONDITIONALLY (via ScrollRegionManager.reassert/1, one CSI 1;(H-N) r write regardless of whether geometry changed) and latches needs_keyframe: true -- the resume entry point after an external process owned the terminal (an $EDITOR session that released the region via the canonical suspend bytes).

The reflow-aware detection SEAM (thin — does not implement reflow-aware re-emission itself, see moduledoc). true only for terminal identities the terminal-matrix probe confirmed reflow sealed history cleanly on resize (iTerm2 reflows sealed history cleanly on resize; it was the only terminal resize-testable on real hardware). Every other identity — including nil/unmeasured (ghostty), and the two terminals measured NOT to reflow (wezterm, kitty) — is conservatively false. Support defaults to seal-time-only and only turns on where reflow-aware behavior is earned: "earned" means measured on real hardware, never assumed from a $TERM_PROGRAM guess.

The diff-driven footer repaint: sanitizes every line of new_lines through ContentGuard.sanitize_line/1 (footer content is agent/LLM- originated, same as the history append path -- see the moduledoc's ContentGuard section), pads/truncates the sanitized result to the CURRENT footer row count, diffs against the last-painted footer (footer_diff/2), and emits only the changed rows -- each CUP (inside the footer range, never history) + \e[K (per-row clear, never \e[2J) + the new line content -- inside one with_cursor/3 bracket. Zero changed rows emits zero bytes (but footer_lines is still updated to the padded/sanitized content, so a later resize that changes footer row count doesn't diff against a stale-length list). Updates footer_lines so the next call diffs against what was actually painted.

Re-derives the DECSTBM history/footer split for a new geometry (via the scroll-region manager's ScrollRegionManager.resize/2, one CSI 1;(H-N) r write) and clamps the append cursor. Deliberately does NOT also repaint the footer's on-screen content here -- an existing regression test on the append path (renderer_adversarial_property_test.exs, "the ONLY new bytes after resize are ScrollRegionManager's single DECSTBM re-set") pins this callback to emit nothing else, and folding a footer keyframe in here would silently break that pinned byte-count. Callers that also need the footer redrawn at the new geometry compose explicitly: authority |> resize(w, h) |> keyframe(current_footer_lines) -- see the moduledoc's "pinned footer viewport" section. Neither call ever emits \e[2J/\e[3J or addresses a history row, so the composition inherits both properties.

The full cursor-ownership protocol for one sealed append: save the cursor, position into the history region (via append_sealed/2), emit iodata, restore. This is the entry point the append path's caller (and later units building on this substrate) should use — append_sealed/2 alone only positions+emits; seal/2 is what makes that a single, save/ restore-bracketed operation.

Grows or shrinks the footer viewport by new_footer_rows rows, WITHOUT a real terminal resize (rows/width unchanged) -- see the moduledoc's "Growing/shrinking the footer" section for the full rationale. This is the seam a footer-hosted overlay uses to claim or release rows.

Closes (or re-attempts closing) the DEC 2026 synchronized-update bracket: when sync_close_pending? is set, writes Dialect.sync_end/0 (CSI ? 2026 l) and clears the latch iff the device accepted the byte. A byte-free no-op when no close is owed -- safe to call on every frame.

Opens a DEC 2026 synchronized-update bracket (Dialect.sync_begin/0, CSI ? 2026 h) -- the first half of the pair sync_close/1 completes. See the moduledoc's "Synchronized output" section for the frame shape.

The write-checked seal: validates and sanitizes iodata (via validate_seal_iodata!/1, the same discipline seal/2 uses -- a missing \r\n terminator is a CALLER-CONTRACT bug and still raises ArgumentError, unmasked), then attempts the write and reports whether the io server ACCEPTED it.

The UNCLAIMED history span -- the rows between the append point (next_row) and the history bottom that hold no sealed content and no shell content (guest boot never claims rows ABOVE the probed cursor; everything from next_row down to the split is this surface's own blank space). {:ok, {from, to}} inclusive, or :none when nothing is unclaimed (e.g. a floating footer sitting flush on the planned split). This is the placement referent for transient elements like the boot greeting: they may only ever exist where neither print-once history nor the user's shell has any bytes.

Types

cursor_park()

@type cursor_park() :: {pos_integer(), pos_integer()} | nil

entry_mode()

@type entry_mode() :: :fill_down | :scroll_entry

How a PINNED seal enters the history region (chat semantics -- the conversation sticks to the bottom):

  • :fill_down -- today's default and every byte-golden world's model: a seal CUPs to min(next_row, history_bottom) and fills downward; the region scrolls only once full. This is the load-bearing model for oracle high-water accounting on streams that boot over a blank region (see the moduledoc's "Seal-once, by construction" section).
  • :scroll_entry -- sealed content enters at the region's BOTTOM row from the first seal on: the seal CUPs to history_bottom and every \r\n is an index-at-region-boundary scroll. In the under-filled phase (next_row < history_bottom -- a guest boot pinned mid-screen) the rows above (shell content, then blanks) scroll up and evict into native scrollback: shell content preserved in order, blank rows evicted as blanks -- the documented dirty-scrollback cost, bounded by ONE screenful (the history_bottom - 1 rows that sat above the entry row at the first seal). Sealed bytes are still never re-addressed: the terminal relocates rows; this module rewrites nothing. After the first scroll-entry seal next_row == history_bottom and every subsequent seal is byte-identical to :fill_down's own steady state (the two modes only ever diverge while under-filled).

The guest bottom-pin boot (:boot_cursor + :guest_placement :bottom_pin) sets :scroll_entry automatically -- input at the screen bottom implies content enters there too. Floating seals ignore this (the floating footer is content-anchored by construction).

pin_state()

@type pin_state() :: :pinned | :floating

The footer-placement state machine (FOOTER-FOLLOWS-CONTENT):

  • :pinned -- today's model: DECSTBM active, footer at the bottom footer_rows rows of the screen. The only state a pin: :immediate (default) authority ever inhabits.
  • :floating -- the adaptive-pin boot state (pin: :adaptive): NO scroll region is set (the whole screen keeps the terminal's default full-screen scrolling), and the footer is painted at absolute rows directly below the last content row -- next_row..(next_row + footer_rows - 1). next_row is the single source of truth for the floating footer's position (deliberately not a second {:floating, content_rows} copy, which could only drift from it).

The float->pin transition is ONE-WAY per session (content only grows) and fires the moment content reaches the pinned footer position -- see the moduledoc's "The adaptive pin" section.

t()

@type t() :: %Raxol.UI.Rendering.PaintAuthority.InlineAuthority{
  cursor_park: cursor_park(),
  entry_mode: entry_mode(),
  footer_lines: [binary()],
  in_cursor_bracket: boolean(),
  needs_keyframe: boolean(),
  next_row: pos_integer(),
  pin_state: pin_state(),
  reflow_capable?: boolean(),
  region: Raxol.Terminal.ScrollRegionManager.t(),
  sync_close_pending?: boolean(),
  sync_output?: boolean(),
  width: pos_integer()
}

Functions

degenerate?(inline_authority)

@spec degenerate?(t()) :: boolean()

True when this authority's current geometry cannot form a valid DECSTBM region — i.e. the footer is NOT actually pinned right now (see the moduledoc's "Degenerate geometry" section). A thin delegation to ScrollRegionManager.degenerate?/1; no behavior change of its own. Callers that need to know whether the pin is real should check this rather than assuming new/5/resize/3 always succeeded.

device_io_error?(exception, stacktrace)

@spec device_io_error?(Exception.t(), Exception.stacktrace()) :: boolean()

Whether an exception is a DEVICE failure raised by the :io layer -- the discriminator scoping try_seal/2's (and the sync bracket's) rescue to the device seam and nothing else.

True only when BOTH hold:

  • the exception is one of the two classes the io layer raises for a failed write: ArgumentError (the io server replied {:error, reason}) or ErlangError (dead device, canonically original: :terminated), and
  • the stack head names the :io module as the raiser -- i.e. the raise came out of :io.put_chars/2 (or a sibling io call), not from this module's own logic, :binary, String, or anything else that happens to raise the same exception class.

The same exception class raised by NON-device code returns false, so callers re-raise it loudly instead of misclassifying a logic bug as a retryable write failure.

Note this answers "is it a device io error", NOT "is it worth retrying" -- those are different questions. A dead device (%ErlangError{original: :terminated}) IS a device io error by this predicate, but the rescue sites here treat it as fail-fast, never retryable: the io-server pid is gone and can never come back, so a retry loop against it would spin silently forever (the round-2 review finding). See try_seal/2's "Only RETRYABLE device failures" section.

erase_transient(t, rows)

@spec erase_transient(t(), [pos_integer()]) :: t()

Erases transient rows (targeted EL per row, never \e[2J), cursor save/restore bracketed. The boot greeting's exit: called by the assembly layer immediately BEFORE the first seal's bytes, in the same frame, so the greeting vanishes exactly when real content arrives and never coexists with (or enters) sealed history.

keyframe(t, new_lines, opts \\ [])

@spec keyframe(t(), [binary()], keyword()) :: t()

Footer keyframe: every footer row cleared (\e[K, never a full-screen clear) and rewritten, regardless of what changed. The Ctrl-L recovery entry point, and what a caller composes after resize/3 to re-derive the footer's on-screen content at the new geometry (see the moduledoc's "pinned footer viewport" section for why resize/3 itself does not call this) -- and what repaint/2 self-promotes to when needs_keyframe is set. Sanitizes every line of new_lines through ContentGuard.sanitize_line/1 (same as repaint/2), then pads/ truncates to the CURRENT footer row count.

When the current footer row count is zero (degenerate geometry, see degenerate?/1), returns t immediately -- no with_cursor/3 bracket is opened at all. Emitting an empty \e7/\e8 save/restore pair over zero addressed rows would be a byte-for-byte no-op wrapped in ceremony; on a geometry that can't show a footer at all, emitting nothing is the honest behavior.

Accepts the same :cursor park option as repaint/3 (see that doc's "park protocol" section); a keyframe always re-emits the park when one is given, since the screen state it recovers from (post-resize, post-editor-resume) says nothing about where the cursor was left.

new(device, width, rows, footer_rows, opts \\ [])

@spec new(
  IO.device(),
  pos_integer(),
  pos_integer(),
  non_neg_integer(),
  keyword()
) :: t()

Builds a new authority: sets the DECSTBM region via ScrollRegionManager.start/3 (one write, CSI 1;(H-N) r), records whether this session's terminal is on the reflow-aware detection allowlist, and starts the fill-down cursor (next_row) at the region's first row.

Options

  • :capabilities — a %Raxol.Terminal.Capabilities{} (or nil) used by reflow_capable?/1. Defaults to the cached session record (Raxol.Terminal.Capabilities.cached/0) if present, else nil. Tests should pass this explicitly rather than relying on the process-global :persistent_term cache.
  • :pin:immediate (default) pins the footer at the screen bottom from the first byte, exactly today's model (ScrollRegionManager.start/3's single DECSTBM write). :adaptive starts FLOATING instead: ZERO bytes are written at construction (ScrollRegionManager.plan/3, the pure geometry record), the footer paints directly below the last content row (the top of the screen on boot), and the authority transitions one-way to the pinned model the moment content reaches the pinned footer position — see the moduledoc's "The adaptive pin" section. The default is deliberately :immediate so every existing byte-golden world stays reachable unchanged; demos opt in.
  • :boot_cursor — GUEST-BOOT placement: {row, col} (1-based), the DSR-probed position where the user's shell left the cursor (Raxol.Terminal.InlineDriver.probe_cursor/2 is the shipped prober). Requires pin: :adaptive (raises ArgumentError otherwise — a pinned boot homes the cursor via DECSTBM and has no placement to inherit). The floating footer starts at the probed row instead of row 1, so on a real shell the surface begins exactly where the prompt stopped. When the probe row sits too low for the footer to float above the pinned split (row > history_bottom — the normal near-bottom shell prompt), construction takes the SCROLL-ENTRY path: the same one-way float->pin transition a seal would fire — plain \ns at the screen bottom scroll the shell's own history up honestly (exactly what any program printing N lines does), one region write claims the pin, and the surface is bottom-anchored from the first frame. Shell content is never repainted: every byte this path emits addresses the probe row or below. A col > 1 probe reply means the shell left an unterminated line on the probe row; boot advances past it with one native \r\n (which scrolls honestly when the probe row is the bottom row) rather than overwriting it. Placement is only honest if nothing wrote to the device between the probe reply and this call. Default nil — byte-identical to today's boot.
  • :entry — how a PINNED seal enters the history region (see the entry_mode typedoc): :fill_down (default — every byte-golden world) or :scroll_entry (chat semantics — sealed content enters at the region bottom and scrolls upward; the under-filled void between conversation and footer becomes unrepresentable). The guest bottom-pin boot sets :scroll_entry itself; this option is for pinned-from-boot embedders that want chat entry too (the demos' probe-failed fallback).

paint_transient(t, row, col, content)

@spec paint_transient(t(), pos_integer(), pos_integer(), iodata()) :: t()

Paints one TRANSIENT line at an absolute {row, col} inside the unclaimed span (see unclaimed_span/1 -- the caller owns the placement decision; this function only refuses off-screen rows). Cursor save/restore bracketed, content routed through ContentGuard.sanitize_line/1 like every other emitted line. NO bookkeeping changes: a transient element is not history (next_row does not move), not footer (no repaint diff state), and the caller is responsible for erasing it (erase_transient/2) before sealed content can ever reach its rows -- the boot greeting's first-seal erase law.

reassert(t)

@spec reassert(t()) :: t()

Re-asserts the DECSTBM history/footer split UNCONDITIONALLY (via ScrollRegionManager.reassert/1, one CSI 1;(H-N) r write regardless of whether geometry changed) and latches needs_keyframe: true -- the resume entry point after an external process owned the terminal (an $EDITOR session that released the region via the canonical suspend bytes).

Why resize/3 alone cannot do this

resize/3's region re-emission is geometry-gated (see ScrollRegionManager.resize/2's "Geometry-gated resize emission"): when the terminal was NOT resized while suspended, history_bottom is unchanged and resize writes ZERO region bytes -- silently leaving the region released even though this struct still believes the footer is pinned. The documented resume composition is therefore

authority |> resize(new_w, new_h) |> reassert()

-- resize/3 handles a mid-suspend geometry change (and may emit its own region bytes for it; the duplicate emit from reassert/1 in that case is harmless -- identical, idempotent bytes), reassert/1 guarantees the pin for the unchanged case.

Why the latch instead of an explicit keyframe

The editor repainted arbitrary screen content while it owned the tty, so the last-painted footer_lines no longer describe what is on-screen -- a logical diff against them would be a lie. Setting needs_keyframe (a pure state change, zero bytes) makes the NEXT repaint/2 self-promote to a full keyframe/2 (the existing post-resize ghost-content mechanism, see the moduledoc), so the first ordinary footer paint after resume fully re-renders every footer row with no new paint code and no second keyframe call site.

Never emits \e[2J/\e[3J; never addresses a history row. Sealed history above the footer survives the whole suspend/resume bracket untouched by construction.

reflow_capable?(arg1)

@spec reflow_capable?(Raxol.Terminal.Capabilities.t() | nil) :: boolean()

The reflow-aware detection SEAM (thin — does not implement reflow-aware re-emission itself, see moduledoc). true only for terminal identities the terminal-matrix probe confirmed reflow sealed history cleanly on resize (iTerm2 reflows sealed history cleanly on resize; it was the only terminal resize-testable on real hardware). Every other identity — including nil/unmeasured (ghostty), and the two terminals measured NOT to reflow (wezterm, kitty) — is conservatively false. Support defaults to seal-time-only and only turns on where reflow-aware behavior is earned: "earned" means measured on real hardware, never assumed from a $TERM_PROGRAM guess.

repaint(t, new_lines, opts \\ [])

@spec repaint(t(), [binary()], keyword()) :: t()

The diff-driven footer repaint: sanitizes every line of new_lines through ContentGuard.sanitize_line/1 (footer content is agent/LLM- originated, same as the history append path -- see the moduledoc's ContentGuard section), pads/truncates the sanitized result to the CURRENT footer row count, diffs against the last-painted footer (footer_diff/2), and emits only the changed rows -- each CUP (inside the footer range, never history) + \e[K (per-row clear, never \e[2J) + the new line content -- inside one with_cursor/3 bracket. Zero changed rows emits zero bytes (but footer_lines is still updated to the padded/sanitized content, so a later resize that changes footer row count doesn't diff against a stale-length list). Updates footer_lines so the next call diffs against what was actually painted.

Self-promotes to a full keyframe/2 -- clearing needs_keyframe in the process -- when that flag is set (a prior geometry-changing resize/3): see the moduledoc's "needs_keyframe latch" section for why a diff-only repaint is not safe to trust immediately after a resize.

The :cursor option (the park protocol)

cursor: {row_offset, col} (0-based row offset from the footer's top row, 1-based column) declares where the terminal's VISIBLE cursor belongs after this paint -- the composer's edit point, for the assembled harness. Without it, nothing ever positions the visible cursor: ScrollRegionManager.start/3's DECSTBM set homes it to (1,1) as a documented VT100 side effect, and every with_cursor/3 bracket faithfully restores it there -- a blinking box parked at the top-left for the whole session (the live-demo defect this closes).

Contract:

  • any paint that emitted rows ends its byte tail with the park CUP (Dialect.cursor_position/2, clamped inside the footer range and the authority width);
  • a frame with NO row changes emits the park CUP alone -- and only when the park actually moved (the zero-byte no-op property is unchanged for a fully-unchanged frame);
  • a MULTI-row paint is a burst: wrapped in Dialect.cursor_hide/0 ... Dialect.cursor_show/0 so the parked cursor never visibly hops row to row mid-rewrite -- UNLESS the frame is already inside an open DEC 2026 bracket (sync_close_pending?), which makes intermediate states invisible without hiding;
  • omitting :cursor (every pre-existing 2-arity caller) is byte-identical to the pre-park behavior -- strictly opt-in.

resize(t, width, height)

Re-derives the DECSTBM history/footer split for a new geometry (via the scroll-region manager's ScrollRegionManager.resize/2, one CSI 1;(H-N) r write) and clamps the append cursor. Deliberately does NOT also repaint the footer's on-screen content here -- an existing regression test on the append path (renderer_adversarial_property_test.exs, "the ONLY new bytes after resize are ScrollRegionManager's single DECSTBM re-set") pins this callback to emit nothing else, and folding a footer keyframe in here would silently break that pinned byte-count. Callers that also need the footer redrawn at the new geometry compose explicitly: authority |> resize(w, h) |> keyframe(current_footer_lines) -- see the moduledoc's "pinned footer viewport" section. Neither call ever emits \e[2J/\e[3J or addresses a history row, so the composition inherits both properties.

When the resize changes EITHER axis -- vertical geometry (ScrollRegionManager.geometry_changed?/2) OR width (t.width != width) -- also sets needs_keyframe: true -- a pure state change, zero bytes -- so the NEXT repaint/2 call self-promotes to a full keyframe/2 instead of trusting a diff against footer content whose on-screen ROW POSITIONS moved (vertical) or whose width-correct truncation went stale / whose sealed history rewrapped (horizontal). This is independent of reflow_capable?/1/the telemetry hook below: the ghost-content risk applies to every terminal's footer, not just the reflow-capable subset.

seal(t, iodata)

@spec seal(t(), iodata()) :: t()

The full cursor-ownership protocol for one sealed append: save the cursor, position into the history region (via append_sealed/2), emit iodata, restore. This is the entry point the append path's caller (and later units building on this substrate) should use — append_sealed/2 alone only positions+emits; seal/2 is what makes that a single, save/ restore-bracketed operation.

iodata MUST be a whole number of \r\n-terminated lines (mirrors Raxol.Harness.Test.SealOracle.assert_seal_newline_terminated/1's discipline) — a dangling partial line would leave the emulator's reported column mid-row, and would under-count next_row's advance against what the terminal actually did. This is now an ENFORCED precondition, not just prose: seal/2 raises ArgumentError when iodata does not end in \r\n.

Content is not trusted (ContentGuard)

iodata is agent/LLM-originated content, not renderer-generated bytes — it can carry ANYTHING a language model chooses to emit, including control sequences that would otherwise defeat every invariant this module exists to hold from the INSIDE (a \e[2J wipes native scrollback same as if this module had written it itself; a \e[1;1H repaints an already-sealed row same as any other bug class this module's fill-down design defends against). Before the newline check and before append_sealed/2 ever sees the bytes, seal/2 runs iodata through Raxol.UI.Rendering.PaintAuthority.ContentGuard.sanitize_line/1, which allowlists printable text, the shared SGR vocabulary, and \t/\r/\n, neutralizing everything else. See that module's moduledoc for the exact grammar and the "visible-honest" neutralization rationale.

Caller contract: line width (NOT enforced here, unlike the newline check)

Autowrap (DECAWM) is never turned off on the inline path -- unlike ViewportAuthority.enter/0's \e[?7l, init_bytes/0 and @modes_off in Raxol.Terminal.InlineDriver.Sequences carry no ?7l, and teardown re-enables ?7h -- so a real terminal is free to wrap any physical line wider than its column count onto the row below. count_lines/1 (which drives every next_row advance below) counts \r\n boundaries in iodata, NOT physical rows a wrapping terminal actually consumes: a line wider than the authority's width occupies MORE physical rows than count_lines/1 credits it, so next_row under-counts against reality and the NEXT seal's :fill_down CUP lands on the wrapped tail of THIS seal's last line instead of a blank row -- a seal-once (immutable-prefix) violation despite every byte this module itself wrote being correct. Exactly the same failure shape the footer section above documents for repaint/2/keyframe/2, and the same fix: it is the CALLER's responsibility to display-width-truncate every line to width (via Raxol.UI.TextMeasure, never String.length/1) before it ever reaches seal/2. ContentGuard guards against malicious CONTROL BYTES, not against width -- an all-printable, perfectly sanitized line can still be too wide, and neither seal/2 nor count_lines/1 can detect that after the fact (SGR runs inflate a raw byte-width measurement without inflating the terminal's actual column consumption, so there is no cheap assertion to add here short of a full ANSI-aware wrap simulator; see test/harness/inline_authority_seal_width_test.exs for a VT-replay regression that pins this exact failure mode against a real emulator so the contract cannot silently rot).

sync_close(t)

@spec sync_close(t()) :: t()

Closes (or re-attempts closing) the DEC 2026 synchronized-update bracket: when sync_close_pending? is set, writes Dialect.sync_end/0 (CSI ? 2026 l) and clears the latch iff the device accepted the byte. A byte-free no-op when no close is owed -- safe to call on every frame.

Why the latch instead of "close in an after-block"

An attempted close is not a delivered close: if the device accepts the OPEN and then refuses the close write (transient enospc, a device dying mid-frame), the terminal is left frozen in synchronized mode -- and a fire-and-forget close attempt would leave it that way until the process exits. The latch makes the owed close durable state: calling this at the top of every frame (advance/tick/input/resize) means a dangling open heals at the first frame after the device accepts a byte again. The residual window is therefore "until the next frame with a writable device" -- never "forever" -- and, since a refused SEAL write guarantees a retry frame, the common failure topology heals immediately.

The wedge-then-QUIT topology (a close stranded on the session's final frame, no later frame to heal on) is covered one layer down: the inline driver's canonical teardown AND editor-suspend byte sequences (Raxol.Terminal.InlineDriver.Sequences, step 1) emit an unconditional ?2026l backstop -- harmless when nothing is owed, DEC private modes being set/reset.

sync_open(t)

@spec sync_open(t()) :: t()

Opens a DEC 2026 synchronized-update bracket (Dialect.sync_begin/0, CSI ? 2026 h) -- the first half of the pair sync_close/1 completes. See the moduledoc's "Synchronized output" section for the frame shape.

Capability-gated, capability-unknown-means-don't-emit

sync_output? is measured once, at new/5, from the capability record passed in (nil/unknown -> false); without it this function is a byte-free no-op. Never emit a presentation-only control sequence on a guess.

The sync_close_pending? latch

A successful open sets sync_close_pending?: true -- "a close byte is owed to the terminal." The latch is cleared only by a close write the device ACCEPTS (sync_close/1), which is what makes the pair's balance claim byte-accurate rather than attempt-accurate: an open that landed is remembered until its close lands, however many attempts that takes. Opening while a close is still owed (a prior frame's close was refused) is harmless -- DEC private modes are set/reset, not counted, so a second ?2026h on an already-synchronized terminal changes nothing, and the still-set latch keeps the close owed.

A failed open degrades gracefully

If the opening write itself is REFUSED (the alive-but-refusing device class -- a dead device re-raises, same fail-fast rationale as try_seal/2's corpse rule; anything non-device re-raises too), the latch is left as it was and the frame simply runs unbracketed: this is a PRESENTATION-only feature and must never take down the frame it wraps.

try_seal(t, iodata)

@spec try_seal(t(), iodata()) :: {:ok, t()} | {:error, :write_failed, t()}

The write-checked seal: validates and sanitizes iodata (via validate_seal_iodata!/1, the same discipline seal/2 uses -- a missing \r\n terminator is a CALLER-CONTRACT bug and still raises ArgumentError, unmasked), then attempts the write and reports whether the io server ACCEPTED it.

What "accepted" means (and does not)

{:ok, _} means the device's io server replied :ok to the write request -- accepted-into-the-io-server. For a StringIO/test device that IS end-to-end delivery; for a buffered pipe or :stdio it means the bytes were handed off, not that they were rendered on a screen. Proving end-to-end delivery would need a DSR round-trip this module does not do. "Accepted" is still the load-bearing property for print-once accounting: a block is only ever marked committed for bytes the device did not refuse.

Write -> confirm -> mark

This is the substrate half of the print-once safety property documented in Raxol.Harness.SealFrontier.commit_walk/5: a caller marks a block committed only AFTER try_seal/2 returns {:ok, _} -- never before. On {:error, :write_failed, t}, the ORIGINAL t (the one passed in, next_row not advanced) is returned, so a retry re-positions and re-writes from scratch rather than resuming from a cursor that may have been left mid-write.

Only RETRYABLE device failures are converted to :write_failed

The rescue below is scoped twice, by retryable_device_error?/2:

  • It must be a device failure at all (device_io_error?/2 -- one of the two device classes AND raised by the :io layer itself, the stack head naming the raiser). Anything else RE-RAISES: an ArgumentError/ErlangError raised by non-device code inside the seal path is a logic bug, and reclassifying it as :write_failed would turn it into an unbounded silent retry loop (the same entry re-attempted every frame, forever, with the real error never surfaced).
  • It must be plausibly TRANSIENT. The {:error, reason} io reply (ArgumentError from :io.put_chars, e.g. enospc) is: the device is alive and answering, and may accept the retry. A DEAD device (%ErlangError{original: :terminated} -- the io-server process is gone) is NOT: a pid never comes back, so retrying is retrying a corpse, forever and silently. That case RE-RAISES too -- the loud crash is the honest outcome for a device that can never heal (and is exactly what the pre-two-phase seal/2 did).

The validation raise above happens BEFORE the rescued block even starts -- a missing \r\n is a bug in the calling code, not a device failure. Likewise, with_cursor/3's own nested-bracket RuntimeError is deliberately NOT rescued -- a caller bug, not something a device retry can fix.

A refusing-but-alive device CAN loop indefinitely (each frame retries, each retry may be refused again). That loop is deliberate -- a bound would strand the block if the device recovers on attempt N+1 -- but it is not silent: Raxol.Harness.SealFrontier.commit_walk/5 halts on {:error, :write_failed, _} and retries that same entry on the next pass rather than silently skipping past it, so a persistent refusal is observable from the first frame rather than vanishing into a retry loop no caller can see.

Partial-write honesty (and the scroll-boundary residual)

On an io-server transport whose requests are accepted or refused as a unit (a StringIO, the BEAM's own tty io server -- everything this module is driven by today), a refused write leaves the screen untouched and a retry simply re-positions and re-writes: the retry produces the same rows the accepted write would have, and next_row/ the committed set never advanced, so print-once accounting holds.

A raw-fd/pty transport that can PARTIALLY flush before erroring is weaker, and one case is a real residual: if target_row is at or near history_bottom, partially-flushed \r\ns scroll the DECSTBM region and evict partially-written rows into native scrollback -- which this process can never rewrite. The retry then re-emits the whole block BELOW those evicted fragments: the fragments are permanent (duplicated/garbled) scrollback content. This module cannot detect or repair that without a transactional device; it is named here so the limit is a documented property, not an implied guarantee. (A line-at-a-time write-and-check emit would bound the damage to one row and is the natural follow-up if a partial-write transport ever drives this path; not implemented -- nothing in the current harness stack writes through one.)

unclaimed_span(t)

@spec unclaimed_span(t()) :: {:ok, {pos_integer(), pos_integer()}} | :none

The UNCLAIMED history span -- the rows between the append point (next_row) and the history bottom that hold no sealed content and no shell content (guest boot never claims rows ABOVE the probed cursor; everything from next_row down to the split is this surface's own blank space). {:ok, {from, to}} inclusive, or :none when nothing is unclaimed (e.g. a floating footer sitting flush on the planned split). This is the placement referent for transient elements like the boot greeting: they may only ever exist where neither print-once history nor the user's shell has any bytes.