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.ScrollRegionManagerfor the DECSTBM region/footer split (history_bottom/1,resize/2re-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:devicethe scroll-region manager andInlineDriveralready thread through, so aStringIOpid (orExUnit.CaptureIO) captures bytes with no pty in tests and:stdiowrites for real in production. - The shared
Dialectwire 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 pinned footer viewport (buffer-diff, footer-scoped)
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/1viafooter_row_count/1), diffs againstfooter_lines(the struct field this module now tracks), and emits ONLY the changed rows — each oneCUP(toregion_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 singlewith_cursor/3bracket (:footerregion) 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 — seeresize/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[3Jand 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.
Caller contract: footer line width (NOT enforced here)
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.
Footer ContentGuard, and the needs_keyframe latch
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/2andkeyframe/2both run every caller-supplied line throughContentGuard.sanitize_line/1at entry — BEFORE padding or diffing — sofooter_diff/2andfooter_linesonly ever see already-neutralized content.footer_linesitself is therefore an invariant: once sanitized in, never re-sanitized out, sofooter_diff/2comparing old (already-sanitized) against new (freshly-sanitized) is always an apples-to-apples comparison. - A stale post-resize repaint could leave ghost content.
resize/3clampsnext_rowbut, 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 thusfooter_diff/2's logical, index-based comparison) is unchanged. A subsequentrepaint/2call 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 rowrepaint/2previously left blank via\e[Kat the OLD position). Theneeds_keyframeflag 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 areScrollRegionManager'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 NEXTrepaint/2call checks the flag FIRST and, if set, self-promotes to a fullkeyframe/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.
Growing/shrinking the footer (overlay hosting)
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 + [K, never [2J) 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 [2J/[3J.
The adaptive pin (FOOTER-FOLLOWS-CONTENT)
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_rowcursor 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_rowis the single source of truth for the floating position; every footer paint site derives it throughfooter_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_keyframeso 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_bottomafter 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\nat 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/1never emit region bytes — geometry updates go throughScrollRegionManager.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)
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
@type cursor_park() :: {pos_integer(), pos_integer()} | nil
@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 tomin(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 tohistory_bottomand every\r\nis 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 (thehistory_bottom - 1rows 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 sealnext_row == history_bottomand 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).
@type pin_state() :: :pinned | :floating
The footer-placement state machine (FOOTER-FOLLOWS-CONTENT):
:pinned-- today's model: DECSTBM active, footer at the bottomfooter_rowsrows of the screen. The only state apin: :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_rowis 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.
@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
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.
@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}) orErlangError(dead device, canonicallyoriginal: :terminated), and - the stack head names the
:iomodule 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.
@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.
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.
@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{}(ornil) used byreflow_capable?/1. Defaults to the cached session record (Raxol.Terminal.Capabilities.cached/0) if present, elsenil. Tests should pass this explicitly rather than relying on the process-global:persistent_termcache.: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).:adaptivestarts 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:immediateso 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/2is the shipped prober). Requirespin: :adaptive(raisesArgumentErrorotherwise — 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. Acol > 1probe 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. Defaultnil— byte-identical to today's boot.:entry— how a PINNED seal enters the history region (see theentry_modetypedoc)::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_entryitself; this option is for pinned-from-boot embedders that want chat entry too (the demos' probe-failed fallback).
@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.
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.
@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.
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/0so 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.
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.
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).
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.
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.
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:iolayer itself, the stack head naming the raiser). Anything else RE-RAISES: anArgumentError/ErlangErrorraised by non-device code inside the seal path is a logic bug, and reclassifying it as:write_failedwould 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 (ArgumentErrorfrom: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-phaseseal/2did).
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.)
@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.