Raxol. Harness. Surface
(Raxol v2.6.1)
View Source
The assembled harness (golden-fixture assembly): the HarnessSurface
app composed end-to-end against a replayed fixture session. This is
the first assembled, visually-demoable harness -- no agent lane, fixture
events only.
This module is the pure(-ish) core: an init/update/render-shaped
state machine over a plain map "model", built entirely from already-
merged units. It never depends on raxol_agent (this module's own
acceptance: "No agent lane required" -- see the "Command bifurcation"
section below). It is deliberately NOT wired through Raxol.start_link/2
/ the normal TEA Lifecycle -- the append-path/footer-viewport
substrate this app renders through
(Raxol.UI.Rendering.PaintAuthority.InlineAuthority/FlatAuthority) is
a byte-level pinned-region writer, one layer BELOW the
Preparer -> LayoutEngine -> UIRenderer -> ScreenBuffer pipeline the
normal TEA runtime drives; there is no Component tree to mount here, so
there is nothing for Lifecycle to add. examples/harness_fixture_demo.exs
is the process-level driver (real tty, Raxol.Terminal.InlineDriver for
raw input) built on top of this module's pure functions.
Glossary (the substrate vocabulary, one line each)
New to this lane? These terms recur, undefended, across this module,
Raxol.UI.Rendering.PaintAuthority.InlineAuthority, and
Raxol.Terminal.ScrollRegionManager -- this is the one anchor:
- DECSTBM -- the ANSI "set top/bottom margins" control
(
CSI top;bottom r): confines terminal scrolling to a row range. The harness uses it to split the screen into scrolling history (top) and a pinned footer (bottom). - The pin / pinned footer -- the bottom N rows placed OUTSIDE the DECSTBM scroll region, so history scrolling never moves them; the only surface the harness ever repaints.
- Seal / seal-once -- writing a finished block into the history region exactly once, never repainted afterward; sealed rows eventually scroll into the terminal's own native scrollback, which this process cannot rewrite.
- Index-at-region-boundary -- a line feed on the scroll region's bottom row scrolls the region up one row (the top row is evicted toward scrollback) instead of moving the cursor; how both sealing and the overlay's footer-grow preserve content.
- Keyframe vs. repaint --
repaint/2rewrites only footer rows whose content changed (a diff);keyframe/2rewrites every footer row (the recovery / post-geometry-change path). - Degenerate geometry -- a terminal too short to hold the footer plus a 2-row-minimum history region: DECSTBM cannot pin, and the harness degrades (or, for overlays, refuses) instead of pretending.
Composition (what this module assembles)
- The append path / footer viewport (
InlineAuthority) or the degradation ladder (FlatAuthority, picked byModeSelect.select/3) -- the paint substrate.:tmux_conservativeroutes through the sameInlineAuthorityas:inline_log(perModeSelect's own moduledoc: there is no separateTmuxConservativeAuthority), so this module only branches on:flatvs. everything else. - The block builder (
Raxol.Harness.Projection) -- journal-fold: durable events becomeBlocks,item_deltatraffic becomes the live tail. - The block bodies (
Raxol.UI.Components.Harness.BlockBody) -- fold-aware per-kind body rendering for expanded blocks. - The status strip (
Raxol.Harness.StatusStrip) -- the pinned status line. - The composer (
Raxol.UI.Components.Harness.Composer) -- the prompt. - The keybind layer (
Raxol.UI.Harness.Keymap) -- canonical event -> command. - The input normalizer (
Raxol.UI.Harness.InputEvent) -- canonical event normalization, the shim every input path goes through first. Raxol.Harness.Surface.ViewText-- this unit's own bridge from the Component tree's view maps to the paint authority's flatiodata()rows (see that module's doc for why truncation precedes styling).Raxol.Harness.UnreadDivider-- the pure attention-boundary policy behind the "N new since you looked" footer rule (see "The unread divider" section below).
Precondition #2 -- keymap-first dispatch (binding, load-bearing)
handle_input/2 normalizes the raw event exactly once
(InputEvent.normalize/1) and calls Keymap.resolve/2 before ever
touching the Composer. Only a :passthrough result reaches
Composer.handle_event/3. This is the fix for the named failure mode
(the composer's catch-all previously delegated every unhandled key into
MultiLineInput -- component-first wiring killed ESC-interrupt AND
Tab-steer dead): ESC/Tab are :always binds in Keymap.binds/0, so
they are intercepted here unconditionally, regardless of composing?.
Precondition #3 -- the focus model
composing? defaults true (the composer starts focused -- matching
the spec's own default). ESC (:interrupt) and Tab (:steer) are the
documented exceptions: both are :always Keymap binds, firing
regardless of composing?, and neither is a focus transition (ESC must
never be swallowed as a focus operation, per Keymap's own moduledoc).
Keymap's :not_composing guard (z/j/k) means block-navigation
commands only ever resolve once focus has ALREADY left the composer --
Keymap itself has no bind that performs that transition (there is no
dedicated "focus transcript" key in Keymap.binds/0 today; a v1 scope
note, not a missing precondition -- no keybind spec covers one). This
module therefore owns the transition as explicit, directly-callable
API: focus_transcript/1 (composing? false, enables jump/fold) and
focus_composer/1 (composing? true, the default). A future key or
mouse binding (a command palette, a focus-lens hover per ADR-0012)
wires one of these directly; today's fixture-only assembly exposes them
for a caller (or a test) to invoke. focused_block_id is threaded to
Keymap.resolve/2's context from focused_index (see "Fold/jump"
below).
Precondition #4 -- context_pct producer semantics
None of the shipped golden fixtures (test/fixtures/harness/sessions/)
carry a context-window-size field alongside turn_completed's
usage/cost payload -- there is no honest way to derive a percentage
from token counts alone without a denominator this producer does not
have. So context_pct is never populated by this assembler; the
status strip's own — convention (gated on turn_completed) renders
exactly that every frame. This is a producer decision (the status
strip's moduledoc explicitly reserves it: "the producer decides, not
this module"), not a bug -- fabricating a percentage from unrelated
data would be the dishonest choice this design deliberately rules out.
turn_stage and turn_completed ARE derived (from the last revealed
loop event's type and whether a turn_completed event is the most
recent one), matching the turn-boundary-snapshot semantics the status
strip assumes by default.
Precondition #5 -- the footer contract
Every frame's footer is built as a plain list of lines (status ++
optional live/pending preview ++ Composer's own rendered lines ++
optional one-shot stub notice), run through ViewText.lines/3 for
Composer's tree (status/notice lines are already plain strings), then
handed to InlineAuthority.repaint/2 -- which pads/truncates the LIST
to the current footer row count itself (see that function's doc). This
module truncates every individual LINE to width via ViewText.lines/3
(which uses TextMeasure, never String.length) before that call --
the caller contract InlineAuthority.repaint/2's moduledoc documents as
NOT enforced by that function itself.
The honest-notice law (priority fit before repaint's truncation)
repaint/2's own pad/truncate is POSITION-BLIND (a tail-drop) -- and
the one-shot stub notice is the LAST footer group, so a composed footer
that overflows the row budget would silently eat exactly the honest
refusal/degradation report the notice channel exists to carry (an
integration finding: the overflow only manifests once sibling footer
content stacks up). footer_lines/1 therefore fits the list itself
BEFORE the handoff (fit_footer_lines/3): display order preserved,
discretionary groups yield first (preview, then divider, then the
composer's tail, then an overlay's tail, then status -- each trimmed
from its tail so a group's leading row survives a partial trim), and a
notice is NEVER the row that silently drops -- at a 1-row budget the
notice is the row that wins. Pinned by the "honest-notice law under
footer overflow" describe in diff_expand_surface_test.exs. On resize, resize/2 composes
InlineAuthority.resize/3 |> InlineAuthority.keyframe/2 explicitly (the
documented composition -- resize/3 alone never repaints the footer).
degenerate?/1 is checked before assuming a pin: a degenerate geometry
still calls repaint/2/keyframe/2 (they never crash), just over
whatever footer capacity footer_range/1 reports for that geometry.
While an overlay picker is open (see "The overlay picker" section
below), the layout is instead status ++ overlay lines
(ViewText.lines(OverlayPicker.render(picker), width, :styled)) ++
Composer's lines ++ notice -- the pending/live-tail preview lines are
SUPPRESSED for exactly as long as the overlay is open (the space they'd
occupy is now claimed by the overlay), and return the moment it closes.
The unread divider (live-region honesty)
Raxol.Harness.UnreadDivider decides, purely from caller-injected
block-commit offsets (see that module's own moduledoc), whether a
"N new since you looked" rule should render. This module owns two
things that policy doesn't: WHEN to feed it an offset (blur/1 and
focus/1, the explicit mode-1004 seam; handle_input/2 also feeds
UnreadDivider.input_activity/2 on every keystroke as the fallback
return signal, and move_focus/2 feeds UnreadDivider.viewed/2 on
every jump) and WHERE to render its output: footer_lines/1 inserts
unread_divider_lines/1's single dim line between the status strip
and the pending/live preview, styled through the same ViewText seam
every other footer line uses. The divider is decided at return-time
and rendered ONLY in the repaintable footer -- sealed history is never
touched (enforced byte-for-byte by the integration suite's
sealed-bytes-identical test, not merely asserted in prose), it is
suppressed under an open overlay exactly like the pending preview, and
it is absent in :flat mode (no footer, no live region, so blur/1/
focus/1 are safe no-ops there). It clears the instant move_focus/2
reaches or passes the boundary block -- jumps skip the divider itself
by construction, since focused_index ranges only over
projection.blocks. advance/2 reconciles the policy state against
every projection rebuild, and the render read is itself reconciled
(UnreadDivider.divider/2), so a shrunken rebuild can neither stick
the span nor paint it past the live block count.
DORMANT TODAY: nothing in the production input path calls blur/1 --
the terminal side can parse focus bytes but no driver enables mode
1004 or routes them here (see UnreadDivider's "mode-1004 seam"
section for the full evidence trail). Until that unit lands, the
divider never renders outside the test suites; the keystroke fallback
only ever CLOSES an away state, never opens one.
The overlay picker (footer-region overlay)
open_overlay/3 hosts a Raxol.UI.Harness.OverlayPicker by GROWING the
DECSTBM footer viewport (InlineAuthority.set_footer_rows/2) -- never a
centered modal painted over history, never the alternate screen. The
overlay's rows live entirely inside the (now larger) pinned footer, the
same substrate the composer/status/preview lines already share.
ESC closes the overlay, not the running turn: Keymap's :overlay
guard (see that module's moduledoc) captures ESC as :overlay_dismiss
BEFORE the global :always ESC-interrupt bind ever sees it, as long as
handle_input/2's context carries overlay_open?: true -- which it
does whenever model.overlay is non-nil. Enter, printable characters,
and the arrow keys are deliberately NOT added to Keymap.binds/0 for
the overlay; they stay :passthrough, and THIS module is what routes a
:passthrough event to OverlayPicker.handle_key/2 instead of the
Composer while an overlay is open (see handle_input/2's routing,
below) -- Enter commits the overlay's current selection instead of
submitting the composer's buffer.
open_overlay/3 refuses rather than degrading silently whenever the
current geometry cannot safely host even a minimal overlay: history
must keep at least 2 rows, and the overlay itself needs at least 2 rows
(a query row plus one item row) -- {:error, :insufficient_footer_capacity}, zero bytes, model untouched. A taller
item list than the available capacity is CLAMPED to fit (via
OverlayPicker's own :max_visible option), not refused -- only a
geometry too small for even the 2-row minimum is a hard refusal.
model.footer_rows always stays the BASE value the caller originally
configured; the grown row count lives only in model.authority for as
long as the overlay is open, and close_overlay/1 restores the
authority back to exactly that base value on dismiss or commit.
Full-screen diff expansion (footer maximization)
expand_focused_diff/1 hosts a Raxol.Harness.DiffExpansion scrollable
window over the focused block's diff, by the same GROW-the-footer
mechanism as the overlay picker above (InlineAuthority.set_footer_rows/2)
-- never a centered modal over history, never the alternate screen. The
difference from the overlay is the CLAIM shape: an overlay claims a
small, fixed height (OverlayPicker.height/1); an expansion claims the
LARGEST non-degenerate footer the current geometry can host
(max_overlay_rows/2, the exact same helper, one source of truth --
history still keeps its 2-row minimum). See DiffExpansion's own
moduledoc for the full mechanism ruling -- why this grows the footer
instead of visiting the alternate screen (LC-P-NOALT, the seal oracle's
unverifiable-vocabulary concern, the missing alt-screen compensation
machinery) -- this section only covers the assembly-layer half of that
decision.
The e key (Raxol.UI.Harness.Keymap's :expand_diff, a
:not_composing bind, same guard class as fold/jump) expands the
currently focused block when it is a :diff block. It rides the exact
guard fold/jump already use -- suppressed while composing (plain typed
text) and while an overlay OR expansion is already open (context's
overlay_open? flag is model.overlay != nil or model.expansion != nil
-- see handle_input/2's moduledoc) -- so e can never fire a second,
nested expansion or steal a keystroke from an open overlay's filter
query.
ESC closes the expansion, not the running turn, for the identical
reason ESC closes an open overlay: Keymap's :overlay guard captures
it as :overlay_dismiss whenever context.overlay_open? is true, which
it is for an open expansion too. dispatch_command/2's expansion clause
for :overlay_dismiss precedes the overlay clause (load-bearing order,
same class of ordering the overlay's own ESC-priority note documents),
so an open expansion's ESC always closes the EXPANSION -- the two can
never both be open at once (each refuses while the other is), so this
is not actually an ambiguous case, just an explicit one. q is a second
dismiss key, routed the same way through route_passthrough/3's
expansion clause (alongside j/k/arrow-key scrolling) -- Enter,
other printable characters, and any other special key are inert while
expanded, matching the overlay's own "only the keys the picker actually
understands are wired" discipline. Dismissing restores the footer to
model.footer_rows via set_footer_rows/2, which latches
needs_keyframe -- the trailing paint_footer/1 self-promotes to a
full keyframe, the same byte-identical restore discipline
close_overlay/1 already relies on.
Honest refusals (see expand_focused_diff/1's doc for the full,
ordered list): no footer to grow (:flat mode), no block focused, the
focused block is not a :diff block, the geometry cannot host even the
2-row minimum, or the focused block's content fails
BodyProvider's :diff schema. Every refusal is zero bytes and an
unchanged model; the e keybind path (apply_expand/1) additionally
surfaces each one as an honest, visibly-labeled one-frame footer notice
through the existing stub_notice channel (precondition #6's stub
mechanism), never a silent no-op.
resize/2 mirrors the overlay's force-close discipline for a geometry
that can no longer host the expansion at all, but because the
expansion's claim is "the maximum available," not a fixed height, a
resize that STILL fits does not merely survive unchanged the way an
open overlay does -- the claim is RE-DERIVED at the new geometry every
time, the footer re-grown or re-shrunk to match, and
DiffExpansion.resize_view/3 re-renders the same diff content at the
new width/window, clamping the scroll offset. See resize/2's own doc
for the exact sequencing.
Precondition #6 -- command bifurcation (fixture mode = honest UI stubs)
:interrupt/:steer are the two commands that cross to the agent lane
in the future agent-lane surface (%Command{}, raxol_agent's
channel). This module has no agent lane by design, so both are
rendered as honest, visibly-labeled stubs instead of silently doing
nothing OR pretending to act:
:interrupt-- sets a one-frame footer notice ("interrupt requested (stub -- no agent lane in fixture mode)"), consumed (cleared) after the next paint so it never lingers as a stale claim.:steer-- reuses Composer's OWN already-built queued-steer banner (Composer.update({:set_queued_steer, ...}, composer)) with the composer's current buffer text, mirroring exactly what a real steer would queue (perKeymap's own moduledoc: "the assembly layer that already has the composer's buffer fillspayload.textin before dispatch") -- without an agent lane to actually deliver it to. This is the more honest stub of the two: it is real, shipped UI, not an invented notice line.
:fold_toggle/:jump_next/:jump_prev never leave this module -- they
are pure UI-local state per Keymap's own documented bifurcation.
While an overlay picker is open (model.overlay != nil), :steer is a
documented no-op instead of queuing the composer's buffer: the composer
is frozen mid-pick (its buffer is not what the operator is currently
interacting with), so queuing a steer built from THAT hidden state
would be dishonest UI -- it would claim to queue "what you were about
to send" when what's actually on screen is a filter query, not a
prompt. :interrupt is unaffected (an overlay is transient UI-local
state, not a reason to block the honest interrupt stub).
Fold/jump and the seal-time-only gate -- a translation, not a reuse
The "which blocks may seal" decision itself now lives in
Raxol.Harness.SealFrontier (a shared classifier, not restated per
consumer). frontier_entries/1 expresses the foldable window described
below as the frontier's pending_input? hold on the newest block; the
seal pass (paint_pending_blocks/1) walks it via commit_walk/5, and
the footer's pending preview (pending_block/1) shows the first block
past the walk's own committed cursor (painted_count) -- the
post-commit truth, which equals the pre-commit scan's tail_start on
every successful frame and stays honest (block still visible) when a
seal write is refused. One classifier decides where the frontier
stops; the cursor records where it actually got to.
The preview shows ONE block (two lines of it). Today the two never
differ: the only frontier hold a shipped producer can create is the
foldable window on the NEWEST block, so the unsealed suffix past the
cursor is at most one block long. Two tests in
test/harness/surface_frontier_feed_test.exs guard this together, and
the split matters: the fixture-REPLAY pin only proves the bound holds
over today's shipped corpus (it replays .jsonl, so it structurally
cannot observe a runtime producer -- on its own it would pass
vacuously). Its teeth come from the paired SYNTHETIC test, which builds
the exact runtime hold the corpus lacks -- a mid-list awaiting-input
:approval holding finalized blocks behind it -- and asserts the
frontier genuinely stops there, so more than one block sits past the
cursor. That is the multi-block hold the one-block preview cannot
honor: the moment a producer wires such a hold into a real advance, the
bound breaks and the preview under-reports, forcing the multi-block
tail rendering decision (the live-lane / T13b unit's), never silently
absorbed here.
Raxol.UI.Components.Harness.Block.seal is an item-LIFECYCLE field:
BlockBuilder only ever constructs a block once its source item(s)
complete, always with seal: :sealed (see BlockBuilder.build_block/2).
That means every block the block builder hands this assembler already
reads :sealed by the time it exists at all -- Block.fold_allowed?/2's
own post-seal gate would therefore deny EVERY fold toggle,
unconditionally, which is not what "fold state flips pre-seal" (this
unit's own acceptance criterion) asks for.
The seal-time-only gate this unit actually needs is a DIFFERENT axis:
has this block been PHYSICALLY PAINTED to the terminal's history region
yet (via InlineAuthority.seal/2/FlatAuthority.seal/2)? That is this
module's own painted_count high-water mark, not Block.seal. So every
fold toggle here calls Block.toggle_fold(block, fold_after_seal: :allow) -- deliberately overriding Block's own (inapplicable) default
-- and this module enforces "no fold after physical paint" itself, by
construction: advance/2 always leaves the newest completed block
un-painted for exactly one more advance/2 call (see
paint_pending_blocks/1), so there is a real, multi-step window in
which the trailing block is visible (via the footer's pending-preview
line), foldable, and NOT yet irreversibly on-screen. Once painted, its
fold state is frozen (assigning further overrides for an
already-painted block index is a no-op here, independent of whatever
Block.fold_allowed?/2 would say) -- exactly because the substrate
cannot repaint sealed history (seal-time-only).
The foldable-before-seal window is honest only for one-block-per-advance
The guarantee above -- "the trailing block is visible, foldable, and NOT
yet irreversibly on-screen for at least one advance/2 call" -- holds
precisely because paint_pending_blocks/1 always holds back exactly the
single NEWEST completed block. If one advance/2 call ever materializes
two or more newly-completed blocks in the same step (the block
builder's projection batching more than one durable item into a single
re-project), every block except the last of that batch seals
IMMEDIATELY, in the same step it first appears -- there is no foldable
window for those, because paint_pending_blocks/1 has no notion of
"the newest N blocks," only "all but the newest one." This is not a
defect in this module's own bookkeeping; it is a real limit of the
design, worth naming honestly rather than leaving implied by the
single-block phrasing above. The actual fix belongs one layer down, in
the block builder: a :completed_but_unsealed phase distinguishing
"this block is done" from "this block has been offered a foldable
window," which the block builder does not currently model (tracked as a
follow-up, not part of this unit's scope).
The live tail (delta streaming) has no history-region home
Per the substrate's actual shipped contract, InlineAuthority supports
exactly two things: seal-once history (seal/2, never repainted) and a
repaintable FOOTER viewport (repaint/2/keyframe/2). There is no
third "live, still-mutating history row" primitive in any merged unit --
inventing one is out of this unit's scope (a new substrate primitive,
not an assembly). So both projection.tail (in-progress items, still
accumulating item_delta chunks) and the one pending-not-yet-painted
completed block are rendered as a single preview line INSIDE the
footer, which IS a legitimately repaintable surface every frame --
never in history. This keeps every live/mutable thing inside the one
viewport built for repainting, and everything sealed forever immutable,
which is the seal-time-only contract honestly extended one layer up
rather than worked around.
The sub-binary pinning footgun -- :binary.copy/1 at seal
Research feedback on comparable TUI harnesses (Ink's erase-redraw
pathology, external audit 2026-07) flagged a BEAM-specific memory
footgun this module's own painted_count design is exposed to: a
binary produced by pattern-matching, binary_part/3, or a JSON
decoder's own unescaped-string fast path (Jason does this) is a
SUB-BINARY -- a small header referencing the WHOLE original buffer, not
a copy of just its own bytes (:binary.referenced_byte_size/1 reveals
the difference; byte_size/1 does not). A Block.content string that
is secretly one of these (a stream delta arriving as a slice of one
large network-chunk binary is the shape a live, agent-streamed session
would hit; a multi-KB .jsonl line decoded by a substring-slicing
parser is today's fixture-mode shape) pins the ENTIRE originating
buffer in memory for as long as anything holds the slice -- and this
module's own blocks, once sealed, are retained in projection.blocks
for the life of the session (see "memory residency" in this module's
test suite, the companion regression guard this fix exists for).
paint_pending_blocks/1 is where a block permanently transitions from
"still mutable, still small in count" to "sealed, retained forever,
never touched again" (seal-time-only) -- the boundary this fix cares
about. detach_content/1 walks a block's content map (recursing into
nested maps/lists -- :args, :options, :blast_radius can all nest)
and replaces every binary with :binary.copy/1's independent copy.
One subtlety this module's own full-rebuild-every-advance/2-call
architecture forces: Projection.project/2 rebuilds blocks from
source_events FROM SCRATCH on every single call (there is no
per-block memoization anywhere in the block builder's pipeline), so a
detached copy stored into projection.blocks on one call is GONE -- silently
replaced by a fresh, un-detached rebuild -- the moment the NEXT
advance/2 runs, unless something re-applies the detach every time.
detach_up_to/2 is that something: it re-detaches every already-sealed
index (not just the ones newly crossing into "about to seal" this
step) on EVERY paint_pending_blocks/1 call. This is real, repeated
work -- same order as Projection.project/2's own already-O(n)
per-call rebuild, so it changes the constant factor, not the
complexity class -- but it is what makes the fix actually STICK: a
one-shot copy that only touches the newly-sealing block would be
silently undone by the very next advance/2's fresh projection for
every block sealed in an EARLIER call, which defeats the whole point.
The more foundational fix belongs one layer down, in
Raxol.Harness.Projection.BlockBuilder -- copying at first extraction,
before a sub-binary content string is ever assigned to a Block struct
at all, rather than after the fact here. That module is already merged
on master; this Surface-side copy is the surgical stopgap until a
block-builder follow-up lands the earlier, more foundational fix.
Tracked, not forgotten.
A related, NOT-implemented-here option for a future agent-lane surface
(long-running, intermittently-idle sessions): :erlang.hibernate/Process.hibernate
between turns compacts the process heap and frees any transient
fragmentation the streaming path accumulated while a turn was running --
independent of this fix (hibernation compacts what's ALREADY garbage;
it cannot un-pin a buffer still referenced by a live sub-binary), and
out of scope for a fixture-replay module that has no live idle period to
hibernate during. Noted here as the next thing to reach for, not
implemented.
External editor handoff (the :edit_draft command, Ctrl+E)
Long prompts don't belong in a 6-row footer composer. The Ctrl+E chord
(Raxol.UI.Harness.Keymap's :edit_draft, an :always bind) hands
the composer draft to $VISUAL/$EDITOR via the injected
:editor_session (see new/2's options): the session suspends the
terminal claim (the canonical suspend bytes release the DECSTBM
region, cooked modes come back, the BEAM stdin reader is gated off),
runs the editor synchronously attached to the tty, and resumes (raw
mode, reader, init bytes). What the session deliberately does NOT do
is re-pin the region -- region bytes are owned by THIS model's
authority, so every return branch here composes
InlineAuthority.resize/3 |> InlineAuthority.reassert/1
(resize/3 alone is geometry-gated: a terminal NOT resized while
suspended would get zero region bytes and stay silently un-pinned),
and reassert/1's needs_keyframe latch turns the next
paint_footer/1 into a full keyframe. On editor exit 0 the edited
draft replaces the composer's value (Composer.set_value/2); any
other outcome keeps the original draft and surfaces a one-frame
footer notice through the existing stub_notice channel.
Sealed history above the footer survives the whole bracket untouched
by construction -- no code path here or in the session addresses a
history row, and the suspend bytes contain no \e[2J/\e[3J. The
one documented residual: an editor that does NOT use the alternate
screen may scribble over the not-yet-scrolled on-screen portion of
history (cosmetic; content already in native scrollback is unreachable
to us and to it). :flat mode has no footer composer to hand a draft
back to, so :edit_draft there seals one honest history line saying
so instead of pretending.
The pickers (command palette, jump, session, search)
Four more OverlayPicker consumers ride the same footer-overlay
substrate as the picker described above. Ctrl+P (Keymap's
:open_palette, an :always chord) opens the command palette from
anywhere, including mid-compose -- a chord is never typed text, the same
reasoning as Ctrl+E. g (:open_jump_picker), s
(:open_session_picker), and / (:open_search_picker) are plain
printable letters gated :not_composing, the same class as z/j/k:
they only resolve in transcript-browse mode, never stealing a letter
out of the composer's typed text. open_search_picker/1's entries are
labeled from Block.search_text/1 -- a content-derived search corpus
(kind, summary, AND body text), not just the summary header
open_jump_picker/1's labels use -- clamped per block (see that
function's own doc) before Raxol.Harness.Surface.ViewText.lines/3
ever truncates a rendered row to its display-width budget.
The palette's entries are Keymap.palette_binds/0 (the labeled subset
of the bind table) plus two commands that exist only at THIS assembly
layer, not in Keymap.binds/0 -- "focus transcript" and "focus
composer" (the very transition focus_transcript/1/focus_composer/1
already exposes as direct API). Picking any palette entry dispatches
through the exact same dispatch_command/2 path a keypress takes --
there is no second, parallel execution mechanism for a palette-picked
command. All four pickers (palette, jump, session, search) opt into
Raxol.UI.Harness.OverlayPicker.fuzzy_filter/3 as their filter_fn
(the Raxol.UI.ListScorer adapter), not the default substring filter --
a fuzzy-ranked query is what a "type a few letters, find the entry"
picker needs.
Session-switch semantics (s, switch_session/2) are stated plainly:
the abandoned session's sealed history stays byte-identical above --
print-once, the substrate cannot rewrite it -- while its not-yet-painted
PENDING blocks (the one-block foldable window) are DROPPED, never
sealed late. Replay state (events/revealed/projection/
painted_count/fold_overrides/focused_index/status) resets, the
new session's events append below whatever is already sealed, and the
composer draft plus authority/geometry survive the switch untouched.
Projection panels (read-only footer overlays)
Three more overlays ride the same hosted-overlay footer slot, but are
Raxol.UI.Harness.OverlayPanel instances (never OverlayPicker) --
summonable via the labeled w/m/n panel binds (worktracks/memory/
plan; Keymap's :open_panel command, discriminated by
payload.panel), and therefore via the command palette too, same
invocation-parity guarantee as every other labeled bind. Content is a
read-model folded by Raxol.Harness.PanelProjection from the
projection's retained durable extract meta events, recomputed both
at summon (open_panel/3) and on every footer repaint while open
(refresh_panel_overlay/1, called from paint_footer/1) -- a live
projection, not a one-shot snapshot. Same refusal ladder as
open_overlay/3 (:overlay_already_open/:no_footer/
:insufficient_footer_capacity), surfaced through the same
picker_refusal/2 notice path. Dismissal releases the claimed footer
rows and discards only UI-local panel state (scroll offset); re-summoning
folds the CURRENT retained events without ever touching the block
projection itself.
Merge caveat (see PanelProjection's own moduledoc for the full
statement): the panels build against the frozen meta-event contract
shapes and a contract-shape fixture
(test/fixtures/harness/sessions/projection-panels.jsonl); the
per-class item shapes are ASSUMPTIONS pending verification against real
agent-emitted extract events before this unit's PR merges.
Precondition #7 -- teardown ownership (this module owns NONE)
new/2 sets the DECSTBM history/footer split via
InlineAuthority.new/5 (a CSI 1;(H-N) r write), but this module never
releases it -- there is no Surface.stop/1/terminate/2 here, and
none of the functions above ever emit CSI r (the full-screen scroll-
region release). In the demo (examples/harness_fixture_demo.exs), that
release happens for free because the driver embedding this module is
Raxol.Terminal.InlineDriver, whose own terminate/2 calls
emit_teardown/2 -> Raxol.Terminal.InlineDriver.Sequences.teardown_bytes/1,
which writes release_region/0 ("\e[r") -- among the other canonical
teardown steps -- before the process exits.
A caller that embeds THIS module directly, without InlineDriver (or
any equivalent that already owns scroll-region teardown), inherits no
such cleanup: the terminal is left with a permanent DECSTBM split after
the process exits, which strands the shell prompt inside the old
history/footer region. Such a caller MUST emit the release itself --
at minimum IO.write(device, "\e[r") (CSI r, reset the scroll region
to the full screen), or, for the full canonical teardown order (modes
off, then region release, then autowrap+cursor restore, then move-to-
bottom), call Raxol.Terminal.InlineDriver.Sequences.teardown_bytes/1
directly. This module deliberately exposes no teardown_bytes/1 of its
own: it would either duplicate that module's pinned byte order or drift
from it, and there is exactly one canonical teardown sequence already
shipped for callers to reuse.
Summary
Types
The hosted overlay's state: mod names which module owns picker (
Raxol.UI.Harness.OverlayPicker for the filterable pickers,
Raxol.UI.Harness.OverlayPanel for the read-only projection panels --
see overlay_mod/1), picker holds THAT module's own state (a picker
or a panel, despite the field name predating panels), and on_pick is
the caller-supplied (or default) commit callback, invoked as
on_pick.(model, item) AFTER close_overlay/1 has already restored the
footer to its base row count -- see handle_input/2's :passthrough
routing. A hosted OverlayPanel never produces a pick (see
open_panel/3), so its on_pick is shape-compatible filler only.
Functions
Reveals exactly one more fixture event, re-projects (Projection.project/2
is pure and cheap over a growing prefix -- see the moduledoc), paints any
block that fell out of the "trailing pending" slot as a result (see
paint_pending_blocks/1), refreshes turn/status derivation, and repaints
the footer. now, when given, stamps status.now/status.last_event_at
(the status strip's own no-wall-clock contract: both are plain
caller-supplied integers, never read from a live clock inside this
module).
Appends events (event-shaped maps -- the same fixture wire shape
advance/2 already consumes) to model.events. This is the live-session
seam: a Raxol.Harness.SessionLane subscriber normalizes each incoming
live event through Raxol.Harness.EventBoundary.normalize/1 upstream of
this call, then hands the result here. Appended events are revealed with
advance/2 exactly like fixture events -- there is no separate reveal
path for "live" vs. "fixture" once an event has landed in model.events.
Records that the operator has looked away, for the unread-divider
policy (Raxol.Harness.UnreadDivider.blur/2). This is the explicit
attention API a later focus-event unit (a real terminal focus-out
signal) wires directly -- see UnreadDivider's "mode-1004 seam" doc.
No production caller exists yet (the divider is dormant at runtime
until that unit lands); tests and future drivers invoke this
directly, same as focus_transcript/1.
A no-op in :flat mode's own honest sense: paint_footer/1 never
repaints there, so nothing visibly changes, but the policy state
itself still tracks the boundary (harmless, since flat mode has no
footer for a divider to ever reach).
Closes the currently-open diff expansion (a no-op when none is open),
restoring the footer viewport to model.footer_rows (the base value)
and repainting. InlineAuthority.set_footer_rows/2 latches
needs_keyframe on a shrink, so the trailing paint_footer/1 call
self-promotes to a full keyframe -- the byte-identical restore
discipline the moduledoc documents (mirrors close_overlay/1
verbatim).
Closes the currently-open overlay picker (a no-op when none is open),
restoring the footer viewport to model.footer_rows (the base value)
and repainting. See the moduledoc's "The overlay picker" section.
Closes a live stream opened with new/2's :stream_open option and
flushes every still-held completed block to sealed history.
Expands the currently focused block full-screen, when it is a :diff
block, by growing the DECSTBM footer to the largest non-degenerate
claim (history keeps its 2-row minimum -- max_overlay_rows/2, the
SAME helper open_overlay/3 uses, one source of truth) and hosting a
Raxol.Harness.DiffExpansion scrollable window inside it. See the
moduledoc's "Full-screen diff expansion (footer maximization)" section
for the mechanism ruling.
The PER-TURN release of the fold-before-seal hold: seals every currently-completed block while LEAVING the stream open for future turns.
Records that the operator has returned, for the unread-divider policy
(Raxol.Harness.UnreadDivider.focus/2). See blur/1's doc and
UnreadDivider's "mode-1004 seam".
Returns focus to the composer (the default -- see the moduledoc's precondition #3 note).
Moves focus off the composer onto the transcript (browsing mode) --
this is what enables Keymap's :not_composing binds
(fold_toggle/jump_next/jump_prev) to resolve at all; see the
moduledoc's precondition #3. No dedicated keybind performs this
transition in Keymap.binds/0 today -- callers (tests, or a future
key/mouse binding) invoke this directly.
Builds the seal-frontier entry list (Raxol.Harness.SealFrontier.entry/0)
from the current projection. One entry per completed block, in order;
the live tail never enters the list (a still-streaming item has no
committable form until it completes into a block, so it is
definitionally past the frontier).
The shared PRE-commit frontier consultation: SealFrontier.scan_frontier/3
over frontier_entries/1. Two consumers read it, both BEFORE the
commit pass runs: paint_pending_blocks/1's detach target
(tail_start -- every block at or past "about to seal" gets its
content detached), and seal_frame/3's per-frame synchronized-output
bracket decision (will_commit predicts "this frame seals >= 1
block" -- same entries, same classifier, so it can never disagree
with what the walk actually attempts). turn_running? is derived
from the status snapshot (turn_completed); with today's entry
mapping (no running entries, window hold unconditional) the scan
result is independent of turn state, so the one-step-stale status at
seal time is harmless.
Normalizes raw_event (InputEvent.normalize/1) and resolves it via
Keymap.resolve/2 BEFORE the Composer ever sees it -- see the
moduledoc's precondition #2. A :passthrough result reaches
Composer.handle_event/3 only while composing? AND no overlay/
expansion is open; while an overlay picker is open (model.overlay != nil), a :passthrough result instead reaches
Raxol.UI.Harness.OverlayPicker.handle_key/2 with the SAME normalized
event this function already computed (never re-normalized) -- see "The
overlay picker" section above. While a diff expansion is open
(model.expansion != nil), a :passthrough result is instead consulted
for scroll/dismiss keys directly by this module -- see "Full-screen
diff expansion" below. overlay_open? in the Keymap context carries
BOTH transient-footer-view flags (model.overlay != nil or model.expansion != nil): an open expansion suppresses the same
:not_composing binds (and captures ESC as :overlay_dismiss) an open
overlay would, for the identical reason -- the footer is showing
something other than the transcript/composer, and typed letters must
reach THAT, never fire commands at state hidden behind it. Always
repaints the footer afterward.
Lists fixture session names available under dir -- the .jsonl files
(suffix stripped, sorted) open_session_picker/1 reads. {:error, _}
from File.ls/1 (missing/unreadable directory) yields [], the same
"nothing to pick" shape an empty directory produces.
Builds the initial model. Does not reveal any fixture events yet (call
advance/2 to step through the session) but DOES paint the initial
footer (empty status + composer prompt) so render/1-equivalent state
is always consistent immediately after construction.
Opens the command palette (Ctrl+P): one entry per Keymap.palette_binds/0
label, plus two surface-local commands (focus transcript, focus composer) that exist only at this assembly layer. Picking an entry
dispatches through the exact same dispatch_command/2 path a keypress
takes -- no parallel execution mechanism. Uses
OverlayPicker.fuzzy_filter/3 as its filter_fn. Refusals (a picker
already open, insufficient geometry, flat mode) surface as an honest
notice via picker_refusal/2, same as open_overlay/3's other callers.
Opens the jump-to-block picker (g, transcript-browse only): one entry
per projected block, labeled "<kind> · <summary>" (Block.summary/1).
Picking an entry sets focused_index. An empty block list is an honest
no-op notice rather than an empty overlay.
Opens an overlay picker over items, growing the footer viewport
(InlineAuthority.set_footer_rows/2) by exactly OverlayPicker.height/1
rows and repainting immediately. See the moduledoc's "The overlay
picker" section for the full contract.
Opens a read-only projection panel (kind: :worktracks, :memory, or
:plan) over the same hosted-overlay footer slot open_overlay/3 uses --
growing the footer viewport by Raxol.UI.Harness.OverlayPanel.height/1
rows and repainting immediately. Summon shows the LIVE projection: the
panel's initial content is Raxol.Harness.PanelProjection.render_lines/2
folded over model.projection.source_events (the retained durable
meta events) at the moment of opening, not a stale snapshot.
Opens the transcript search picker (/, transcript-browse only): one
entry per projected block, labeled from Block.search_text/1 (the
full content-derived search corpus, not just Block.summary/1's
header line) -- the overlay's fuzzy filter over these labels IS the
search: it reaches into block BODIES, not just headers. Picking an
entry sets focused_index, exactly like open_jump_picker/1. An
empty block list is an honest no-op notice rather than an empty
overlay.
Opens the session picker (s, transcript-browse only): one entry per
.jsonl fixture in model.sessions_dir (see list_fixture_sessions/1).
Picking a name loads it (Raxol.Harness.Fixture.load/1) and switches to
it via switch_session/2 -- see the moduledoc's session-switch
semantics. An empty directory listing is an honest no-op notice rather
than an empty overlay; a load failure surfaces its DecodeError reason
instead of switching.
Sets (or clears, with nil) a PERSISTENT footer notice line -- rendered
on every paint until replaced or cleared, unlike stub_notice (which
paint_footer/1 consumes after one frame). Intended for live-session
status the embedder wants visible across many frames (e.g. "reconnecting
to live session"), not a one-shot acknowledgment. Repaints the footer
before returning.
Sets (or clears, with nil) the status strip's :stall_verdict seam
(Raxol.Harness.StatusStrip's own documented integration point) and
repaints the footer. The strip already renders the ALERT: <evidence>
segment for a :stalled/:looping verdict with non-empty evidence; this
function is only the model-side plumbing that gets a verdict into
model.status in the first place.
Resizes the geometry. Composes InlineAuthority.resize/3 |> InlineAuthority.keyframe/2 explicitly (the documented composition --
resize/3 alone never repaints the footer) in inline/tmux modes;
FlatAuthority.resize/3 writes zero bytes either way.
Seals ONE honest, plain marker line into the history region at the current append point -- the loss-honesty marker for live streaming (e.g. shed deltas, a rejected/dropped event). This instrument never renders a gapless lie over lost data: when the live lane cannot deliver every event, this is how the transcript says so, instead of silently rendering as if nothing had been lost.
Startup discipline: push any existing dirty screen
content into scrollback via plain newlines, NEVER \e[2J (which would
wipe native scrollback on wezterm/kitty). Callers write this
BEFORE the substrate's scroll region is established (i.e. before
new/2), since InlineAuthority.new/5 only sets the DECSTBM split --
it never clears or pushes anything on its own.
Switches the active session to session_or_events (see the moduledoc's
"The pickers" section for the print-once semantics this implements):
authority/composer/mode/geometry (width/rows)/footer_rows/
sessions_dir/editor_session/editor_opts are left untouched --
sealed history above is never touched by this function at all, which is
the whole point. Replay state resets (events, revealed,
projection, painted_count, fold_overrides, focused_index,
status) so the new session starts its own fresh reveal. Does NOT paint
by itself -- the caller's trailing paint_footer/1 (in
handle_input/2) covers the footer.
Advances the elapsed-since-last-event ticker (the status strip's
Stage slot) without revealing a new fixture event -- elapsed ticks
during a long silent tool call (the status strip's own acceptance).
Plain caller-supplied now, same no-wall-clock discipline as
advance/2.
Types
@type mode() :: :inline_log | :tmux_conservative | :flat
@type overlay() :: %{ :mod => module(), :picker => Raxol.UI.Harness.OverlayPicker.t() | Raxol.UI.Harness.OverlayPanel.t(), :on_pick => (t(), term() -> t()), optional(:folded_at) => non_neg_integer() }
The hosted overlay's state: mod names which module owns picker (
Raxol.UI.Harness.OverlayPicker for the filterable pickers,
Raxol.UI.Harness.OverlayPanel for the read-only projection panels --
see overlay_mod/1), picker holds THAT module's own state (a picker
or a panel, despite the field name predating panels), and on_pick is
the caller-supplied (or default) commit callback, invoked as
on_pick.(model, item) AFTER close_overlay/1 has already restored the
footer to its base row count -- see handle_input/2's :passthrough
routing. A hosted OverlayPanel never produces a pick (see
open_panel/3), so its on_pick is shape-compatible filler only.
mod is the discriminator: OverlayPicker => picker/on_pick are
live; OverlayPanel => on_pick is inert filler and folded_at (the
memoization token, present only for panels) is live. See
refresh_panel_overlay/1.
@type t() :: %{ mode: mode(), authority: Raxol.UI.Rendering.PaintAuthority.InlineAuthority.t() | Raxol.UI.Rendering.PaintAuthority.FlatAuthority.t(), events: [map()], revealed: non_neg_integer(), projection: Raxol.Harness.Projection.t(), fold_defaults: map(), painted_count: non_neg_integer(), fold_overrides: %{ optional([term()]) => Raxol.UI.Components.Harness.Block.fold_state() }, focused_index: non_neg_integer() | nil, composer: map(), composing?: boolean(), width: pos_integer(), rows: pos_integer(), footer_rows: pos_integer(), status: map(), stub_notice: String.t() | [String.t()] | nil, overlay: overlay() | nil, expansion: Raxol.Harness.DiffExpansion.t() | nil, editor_session: module() | (String.t(), keyword() -> term()) | nil, editor_opts: keyword(), unread: Raxol.Harness.UnreadDivider.t(), sessions_dir: Path.t(), command_sink: (map() -> term()) | nil, lane_notice: String.t() | [String.t()] | nil, stream_open?: boolean() }
Functions
Reveals exactly one more fixture event, re-projects (Projection.project/2
is pure and cheap over a growing prefix -- see the moduledoc), paints any
block that fell out of the "trailing pending" slot as a result (see
paint_pending_blocks/1), refreshes turn/status derivation, and repaints
the footer. now, when given, stamps status.now/status.last_event_at
(the status strip's own no-wall-clock contract: both are plain
caller-supplied integers, never read from a live clock inside this
module).
Returns {model, :ok} while events remain, {model, :done} once every
fixture event has been revealed AND the final pending block (if any) has
been flushed to paint.
Options
:resize--{width, rows}, the atomic combined-frame form for drivers that batch a geometry change with the same advance. When given, the resize is ADOPTED (dims + DECSTBM re-set, via the sameadopt_resize/3pathresize/2itself uses, minus that function's own immediate keyframe) BEFORE anything else in this call -- specifically, before any block seals this frame.resize/2remains the standalone entry point; callingresize/2thenadvance/2is equally correct. The:resizeoption exists only for drivers that would otherwise have to sequence two separate calls for what is, to the terminal, one frame.
FRAME-ORDER LAW
A resize arriving in the SAME frame as an advance MUST be adopted
before any seal in that advance: a block sealed at a stale width hard-
wraps over-wide rows, and that wrap is permanent corruption once the
row scrolls into native scrollback (this process can never rewrite it).
This is why the :resize option is threaded through
adopt_frame_resize/2 first, unconditionally, ahead of do_advance/2.
The footer row COUNT in this substrate is geometry-fixed (a function of
rows/footer_rows only, never of post-seal state) -- so the
reference design's "size the footer to the post-seal state" step is
satisfied by construction, with nothing further to do here. The footer
REPAINT itself still runs AFTER the seal (see seal_frame/3): the
trailing paint_footer/1 self-promotes to a full keyframe via
InlineAuthority's own needs_keyframe latch (set by adopt_resize/3
whenever geometry or width changed), so the footer always ends up
correct at the newly-adopted geometry without this module needing a
second, explicit keyframe call here.
Appends events (event-shaped maps -- the same fixture wire shape
advance/2 already consumes) to model.events. This is the live-session
seam: a Raxol.Harness.SessionLane subscriber normalizes each incoming
live event through Raxol.Harness.EventBoundary.normalize/1 upstream of
this call, then hands the result here. Appended events are revealed with
advance/2 exactly like fixture events -- there is no separate reveal
path for "live" vs. "fixture" once an event has landed in model.events.
O(n) per call (model.events ++ events), matching
Raxol.Harness.Projection.project/2's own per-advance/2 O(n) rebuild
-- this call changes the constant factor of a growing session's upkeep,
not its complexity class.
Raises ArgumentError on a non-map element: the boundary normalizer is
expected to run upstream of this call, so a non-map element reaching here
is a caller bug, not a value this function silently tolerates.
Records that the operator has looked away, for the unread-divider
policy (Raxol.Harness.UnreadDivider.blur/2). This is the explicit
attention API a later focus-event unit (a real terminal focus-out
signal) wires directly -- see UnreadDivider's "mode-1004 seam" doc.
No production caller exists yet (the divider is dormant at runtime
until that unit lands); tests and future drivers invoke this
directly, same as focus_transcript/1.
A no-op in :flat mode's own honest sense: paint_footer/1 never
repaints there, so nothing visibly changes, but the policy state
itself still tracks the boundary (harmless, since flat mode has no
footer for a divider to ever reach).
Closes the currently-open diff expansion (a no-op when none is open),
restoring the footer viewport to model.footer_rows (the base value)
and repainting. InlineAuthority.set_footer_rows/2 latches
needs_keyframe on a shrink, so the trailing paint_footer/1 call
self-promotes to a full keyframe -- the byte-identical restore
discipline the moduledoc documents (mirrors close_overlay/1
verbatim).
Closes the currently-open overlay picker (a no-op when none is open),
restoring the footer viewport to model.footer_rows (the base value)
and repainting. See the moduledoc's "The overlay picker" section.
Closes a live stream opened with new/2's :stream_open option and
flushes every still-held completed block to sealed history.
This is the release end of the fold-before-seal ordering contract (see
frontier_entries/1): while the stream is open, the newest completed
block is held un-sealed so that later same-turn events -- the turn
bracket above all -- fold into the projection BEFORE the block is
irreversibly painted. Once no more events will ever arrive (the session
ended, the session process died, the event feed is gone), the hold has
nothing left to wait for; this call drops it and runs the seal pass so
the trailing block lands in history instead of living forever in the
footer preview. Idempotent; a no-op on an already-closed model.
@spec expand_focused_diff(t()) :: {:ok, t()} | {:error, :expansion_already_open | :overlay_open | :no_footer | :no_focus | :not_a_diff | :insufficient_footer_capacity | {:invalid_content, String.t()}}
Expands the currently focused block full-screen, when it is a :diff
block, by growing the DECSTBM footer to the largest non-degenerate
claim (history keeps its 2-row minimum -- max_overlay_rows/2, the
SAME helper open_overlay/3 uses, one source of truth) and hosting a
Raxol.Harness.DiffExpansion scrollable window inside it. See the
moduledoc's "Full-screen diff expansion (footer maximization)" section
for the mechanism ruling.
Refuses, in order (each refusal: zero bytes written, model untouched):
{:error, :expansion_already_open}--model.expansionis already set.{:error, :overlay_open}-- an overlay picker is open (the two transient footer views never coexist).{:error, :no_footer}--model.mode == :flat(nothing to grow).{:error, :no_focus}--model.focused_indexisnil.{:error, :not_a_diff}-- the focused block does not exist, or itskindis not:diff.{:error, :insufficient_footer_capacity}-- the current geometry cannot keep history's 2-row minimum AND host at least a 2-row expansion (one status row and one expansion header row, per the moduledoc's arithmetic).{:error, {:invalid_content, reason}}-- the focused block's content map failsRaxol.UI.Components.Harness.BodyProvider's:diffschema (DiffExpansion.new/2's own validation, consulted BEFORE any row is claimed -- a content error never grows the footer).
The PER-TURN release of the fold-before-seal hold: seals every currently-completed block while LEAVING the stream open for future turns.
Call when a turn bracket (turn_completed / turn_canceled) has
folded: nothing more can ever fold into the blocks that bracket
completed, so holding them any longer serves nothing -- but the session
lives on (a multi-turn conversation runs one turn per prompt on the
same session), so this must NOT close the stream. close_stream/1 is
the terminal sibling for the process-level end-of-stream facts (session
death, dead event feed), and the backstop that guarantees a stranded
tail still lands in history if a session dies mid-turn with no bracket.
Records that the operator has returned, for the unread-divider policy
(Raxol.Harness.UnreadDivider.focus/2). See blur/1's doc and
UnreadDivider's "mode-1004 seam".
Returns focus to the composer (the default -- see the moduledoc's precondition #3 note).
Moves focus off the composer onto the transcript (browsing mode) --
this is what enables Keymap's :not_composing binds
(fold_toggle/jump_next/jump_prev) to resolve at all; see the
moduledoc's precondition #3. No dedicated keybind performs this
transition in Keymap.binds/0 today -- callers (tests, or a future
key/mouse binding) invoke this directly.
@spec frontier_entries(t()) :: [Raxol.Harness.SealFrontier.entry()]
Builds the seal-frontier entry list (Raxol.Harness.SealFrontier.entry/0)
from the current projection. One entry per completed block, in order;
the live tail never enters the list (a still-streaming item has no
committable form until it completes into a block, so it is
definitionally past the frontier).
Field mapping (the design decision this assembly makes):
committed?-- delegated toblock_sealed?/2, THE single-source committed-marker predicate (its doc states thepainted_countcomparison exactly once; restating it here is the drift the unification exists to prevent).running?--Block.live?/1, an honest passthrough. Always false today (the block builder only constructs completed, sealed blocks), which leaves the classifier's mid-turn running exceptions dormant until a producer emits still-running entries.pending_input?-- the frontier gate's invariant is "the rendered form can still change on user interaction; print-once must not freeze it," and this feed derives BOTH instances of it:- A LIVE
:approvalblock (Block.live?/1withkind: :approval) is, perBlock's own contract, a question still waiting on the user -- the genuine awaiting-input lifecycle, held in EVERY turn state and at any position (the gate exists precisely so the idle relaxation can never seal an unanswered prompt past a stale running flag). Dormant today -- the block builder only constructs sealed blocks -- but the gate's contract holds the moment a producer emits live approval blocks. - The NEWEST block while the fixture reveal is unfinished: the one-advance foldable window (see the moduledoc's "Fold/jump and the seal-time-only gate"), expressed in frontier terms -- a fold toggle is the pending interaction. The hold is unconditional on turn state (matching the window's own semantics: it releases on reveal completion, not on turn boundaries). When the block builder later grows a completed-but-unsealed phase, this derivation moves down a layer.
- A LIVE
@spec frontier_scan(t()) :: Raxol.Harness.SealFrontier.scan()
The shared PRE-commit frontier consultation: SealFrontier.scan_frontier/3
over frontier_entries/1. Two consumers read it, both BEFORE the
commit pass runs: paint_pending_blocks/1's detach target
(tail_start -- every block at or past "about to seal" gets its
content detached), and seal_frame/3's per-frame synchronized-output
bracket decision (will_commit predicts "this frame seals >= 1
block" -- same entries, same classifier, so it can never disagree
with what the walk actually attempts). turn_running? is derived
from the status snapshot (turn_completed); with today's entry
mapping (no running entries, window hold unconditional) the scan
result is independent of turn state, so the one-step-stale status at
seal time is harmless.
The footer's pending preview (pending_block/1) deliberately does
NOT read this scan: it keys on the committed cursor
(painted_count) instead, because the scan consumes committable
entries and would therefore hide a block whose seal write was just
REFUSED (see pending_block/1's comment for the full rationale).
The two agree on every successful frame (the scan/walk-agreement
property); they diverge exactly when a write fails, and the cursor
is the display-honest side of that divergence.
Normalizes raw_event (InputEvent.normalize/1) and resolves it via
Keymap.resolve/2 BEFORE the Composer ever sees it -- see the
moduledoc's precondition #2. A :passthrough result reaches
Composer.handle_event/3 only while composing? AND no overlay/
expansion is open; while an overlay picker is open (model.overlay != nil), a :passthrough result instead reaches
Raxol.UI.Harness.OverlayPicker.handle_key/2 with the SAME normalized
event this function already computed (never re-normalized) -- see "The
overlay picker" section above. While a diff expansion is open
(model.expansion != nil), a :passthrough result is instead consulted
for scroll/dismiss keys directly by this module -- see "Full-screen
diff expansion" below. overlay_open? in the Keymap context carries
BOTH transient-footer-view flags (model.overlay != nil or model.expansion != nil): an open expansion suppresses the same
:not_composing binds (and captures ESC as :overlay_dismiss) an open
overlay would, for the identical reason -- the footer is showing
something other than the transcript/composer, and typed letters must
reach THAT, never fire commands at state hidden behind it. Always
repaints the footer afterward.
Lists fixture session names available under dir -- the .jsonl files
(suffix stripped, sorted) open_session_picker/1 reads. {:error, _}
from File.ls/1 (missing/unreadable directory) yields [], the same
"nothing to pick" shape an empty directory produces.
@spec new( Raxol.Harness.Fixture.Session.t() | [map()], keyword() ) :: t()
Builds the initial model. Does not reveal any fixture events yet (call
advance/2 to step through the session) but DOES paint the initial
footer (empty status + composer prompt) so render/1-equivalent state
is always consistent immediately after construction.
Options
:device(required) -- the outputIO.device().:width,:rows(required) -- terminal geometry.:footer_rows(default 6).:env(defaultSystem.get_env/0) -- fed toModeSelect.select_with_reason/3.:tty?-- merged into:envas:tty?(defaulttrue).:capabilities-- a%Raxol.Terminal.Capabilities{}ornil.:fold_defaults-- forwarded toProjection.project/2.:mode-- explicit override bypassingModeSelect.select_with_reason/3entirely (test seam). Bypasses the startup mode notice below too -- an explicit:modeis a test/caller decision, not a pick this module made, so there is noreason()to explain.:editor_session--nil(default), a module implementingRaxol.Harness.EditorSession'srun(draft, opts)contract, or a 2-arity fun with the same contract. Enables the Ctrl+E external- editor handoff (see the moduledoc's "External editor handoff" section);nilrenders an honest stub notice instead. Embedders with a real tty passRaxol.Harness.EditorSession; tests inject a fun returning canned outcomes.:editor_opts-- extra options merged into every editor-session call (e.g.editor_timeout_ms: 60_000, or an explicit vetted:env-- seeRaxol.Harness.EditorSession's trust-boundary section). Model-owneddevice/rows/widthalways win over entries here.:sessions_dir(default"test/fixtures/harness/sessions", the same sourceexamples/harness_fixture_demo.exsreads) -- the directorylist_fixture_sessions/1(the session picker,s) lists.jsonlfixtures from.:command_sink(defaultnil) -- a 1-arity fun that makes:interrupt/:steerLIVE instead of the fixture-mode stubs (see the moduledoc's "Command bifurcation" section).nilkeeps today's honest stubs untouched; a fun receives%{type: :interrupt, payload: %{}}or%{type: :steer, payload: %{text: composer_text}}-- seeRaxol.Harness.SessionLanefor the seam a live implementation dispatches through on the other side.:stream_open(defaultfalse) -- declares that more events may still arrive beyond whateverappend_events/2has delivered so far, so the reveal is never treated as finished merely for being momentarily caught up (seefrontier_entries/1's fold-before-seal note). A live embedder sets this and callsclose_stream/1when the session truly ends; fixture replay keeps the default.
Startup mode notice (the degradation ladder's select_with_reason/3 seam)
When mode-pick is NOT explicitly overridden, this uses
ModeSelect.select_with_reason/3 and surfaces a one-line, visible
notice whenever the reason is :degenerate_clamp (the terminal is too
short for a footer, silently clamped to :flat) or
:override_unrecognized (RAXOL_HARNESS_MODE was set to something
other than flat/tmux/inline and got ignored) -- both cases where
the session is running in a DIFFERENT mode than an operator watching
the startup env might expect, and silence would read as "it just
picked :inline_log as always" rather than "it downgraded and here's
why." Every other reason (:override, :headless, :tmux, :default)
is an unsurprising, correctly-resolved pick -- no notice.
The notice reaches the screen through whichever channel the RESOLVED
mode actually has available: for :flat (the only mode
:degenerate_clamp ever resolves to; :override_unrecognized can also
auto-detect into :flat via the headless rule), paint_footer/1 is a
documented no-op -- flat has no footer -- so the notice is instead
SEALED as the session's first history line, through the exact same
FlatAuthority.seal/2 append path every other flat-mode block uses.
For any other resolved mode, the footer's existing one-shot
stub_notice mechanism (notice_line/2, already consumed by the next
paint_footer/1 call) carries it -- set here, before this function's
own trailing paint_footer/1 call, so the very first rendered frame
shows it.
Opens the command palette (Ctrl+P): one entry per Keymap.palette_binds/0
label, plus two surface-local commands (focus transcript, focus composer) that exist only at this assembly layer. Picking an entry
dispatches through the exact same dispatch_command/2 path a keypress
takes -- no parallel execution mechanism. Uses
OverlayPicker.fuzzy_filter/3 as its filter_fn. Refusals (a picker
already open, insufficient geometry, flat mode) surface as an honest
notice via picker_refusal/2, same as open_overlay/3's other callers.
Because the Ctrl+P chord is :always, entries whose keypress guard is
:not_composing become pickable in states the guard would never allow
-- an entry that is inapplicable in the current state (e.g. "toggle
fold" with no focused block) refuses with an honest one-frame notice
rather than silently doing nothing (see apply_fold_toggle/2's
nil-target clause and the covering "no focused block" tests in
command_palette_surface_test.exs).
Opens the jump-to-block picker (g, transcript-browse only): one entry
per projected block, labeled "<kind> · <summary>" (Block.summary/1).
Picking an entry sets focused_index. An empty block list is an honest
no-op notice rather than an empty overlay.
@spec open_overlay(t(), [term()], keyword()) :: {:ok, t()} | {:error, :expansion_open | :overlay_already_open | :no_footer | :insufficient_footer_capacity}
Opens an overlay picker over items, growing the footer viewport
(InlineAuthority.set_footer_rows/2) by exactly OverlayPicker.height/1
rows and repainting immediately. See the moduledoc's "The overlay
picker" section for the full contract.
Options
Forwarded to Raxol.UI.Harness.OverlayPicker.new/2 (:label_fn,
:filter_fn, :title), except :max_visible, which this function
CLAMPS to the available footer capacity before forwarding (a taller
request is narrowed, never refused -- see below), plus:
:on_pick--(t(), item -> t()), invoked after the overlay has already closed (seehandle_input/2's:passthroughrouting). Defaults to an honest one-frame footer notice ("» picked <label>"), the same stub mechanism:interrupt/:steeruse.
Errors
{:error, :expansion_open}-- a diff expansion (seeexpand_focused_diff/1) is already claiming the footer; the two transient footer views never coexist.{:error, :overlay_already_open}--model.overlayis already set.{:error, :no_footer}--model.mode == :flat(nothing to grow).{:error, :insufficient_footer_capacity}-- the current geometry cannot keep history's 2-row minimum AND host at least a 2-row overlay (query + one item row). Zero bytes written, model untouched.
@spec open_panel(t(), Raxol.Harness.PanelProjection.kind(), keyword()) :: {:ok, t()} | {:error, :overlay_already_open | :no_footer | :insufficient_footer_capacity}
Opens a read-only projection panel (kind: :worktracks, :memory, or
:plan) over the same hosted-overlay footer slot open_overlay/3 uses --
growing the footer viewport by Raxol.UI.Harness.OverlayPanel.height/1
rows and repainting immediately. Summon shows the LIVE projection: the
panel's initial content is Raxol.Harness.PanelProjection.render_lines/2
folded over model.projection.source_events (the retained durable
meta events) at the moment of opening, not a stale snapshot.
Dismissal (close_overlay/1, same as any other hosted overlay) discards
only UI-local state -- scroll offset, claimed footer rows. The fold
source is the projection's retained events, so re-summoning a panel
folds current state without ever touching the block projection itself
(see PanelProjection's moduledoc, "Recompute, not incrementally
cached").
Options
:max_visible(defaultOverlayPanel.default_max_visible/0) -- clamped to the available footer capacity, same narrowing ruleopen_overlay/3applies (a taller request is narrowed, never refused).
Errors
Identical refusal ladder to open_overlay/3:
{:error, :overlay_already_open}, {:error, :no_footer} (:flat
mode), {:error, :insufficient_footer_capacity}. Zero bytes written,
model untouched on every refusal.
Opens the transcript search picker (/, transcript-browse only): one
entry per projected block, labeled from Block.search_text/1 (the
full content-derived search corpus, not just Block.summary/1's
header line) -- the overlay's fuzzy filter over these labels IS the
search: it reaches into block BODIES, not just headers. Picking an
entry sets focused_index, exactly like open_jump_picker/1. An
empty block list is an honest no-op notice rather than an empty
overlay.
The label clamp (bounded WORK on the input path)
Raxol.UI.Harness.OverlayPicker's fuzzy ranker runs synchronously,
per keystroke, over every label's label_fn output -- this Surface is
a synchronous pure state machine (fixture mode has no other thread to
move the work to), and a block body is unbounded, untrusted content
(a fixture's tool-call output, an LLM's streamed response). So each
block's search corpus is clamped to 400 graphemes
via Block.search_text/2, which applies the clamp AT THE SOURCE --
every body field is bounded to the cap BEFORE it is concatenated,
joined, or newline-flattened, and String.slice/3 walks at most that
many graphemes and stops. So open_search_picker/1 does O(cap) work
per block, not O(body-size): the clamp bounds the WORK, not merely the
label output. (An earlier revision clamped only the flattened result,
which bounded the output while the concat + flatten still scanned the
whole untrusted body -- fixed by pushing the clamp into
Block.search_text/2.) This is the per-label GRAPHEME-length axis;
the entry-COUNT axis is left uncapped here, matching the accepted
open_jump_picker/1 precedent (see "No entry cap" below). The named,
honest consequence: body content past the cap is not searchable.
No entry cap (unlike open_session_picker/1)
open_search_picker/1 builds one item per block with no ceiling,
unlike open_session_picker/1's @session_picker_cap entry cap. This
mirrors open_jump_picker/1 (also uncapped) and is deliberate: an
entry cap would silently drop blocks OUT of search, making a block
unfindable -- a correctness regression, not a safety win. With the
per-item work now bounded (see the label clamp above), a single
keystroke's ranking is near-linear in entries x cap, the same
envelope the accepted jump picker already runs in.
Why every label is kind · summary, not "the matching line"
OverlayPicker's label_fn is static: it is both the search key AND
the rendered row, and it never sees the live query as the operator
types -- so a label that shows "the first line that matched" is
structurally impossible for this primitive (there is no query yet at
label-construction time, and the query changes every keystroke without
the labels being rebuilt). Every label is instead Block.search_text/2
itself (kind-prefixed, source-clamped, newline-flattened) -- the visible,
width-truncated HEAD of each row ("<kind> · <summary>") always
identifies the block even when the actual match sits deep in an
unfolded body line, and picking still jumps focus to the real content
underneath. Display-width truncation (CJK-aware) happens exactly once,
in Raxol.Harness.Surface.ViewText.lines/3 -- this function hands the
picker full (already-clamped) labels, same as open_jump_picker/1.
Opens the session picker (s, transcript-browse only): one entry per
.jsonl fixture in model.sessions_dir (see list_fixture_sessions/1).
Picking a name loads it (Raxol.Harness.Fixture.load/1) and switches to
it via switch_session/2 -- see the moduledoc's session-switch
semantics. An empty directory listing is an honest no-op notice rather
than an empty overlay; a load failure surfaces its DecodeError reason
instead of switching.
Listing cap (bounded work on the input path)
sessions_dir is a public option and both the File.ls/1 listing and
the per-keystroke fuzzy ranking run synchronously on the input path --
this Surface is a synchronous pure state machine by design (fixture
mode has no other thread to move them to). So the listing is CAPPED at
100 entries (sorted order, first 100
kept), and the truncation is named in the picker title
("session — first N of M"), never silent. Fixture.load/1 on pick is
likewise synchronous and whole-file; fixture sessions are small by
construction, and a pathological file pauses the loop for the load
rather than crashing anything -- documented, not hidden.
Sets (or clears, with nil) a PERSISTENT footer notice line -- rendered
on every paint until replaced or cleared, unlike stub_notice (which
paint_footer/1 consumes after one frame). Intended for live-session
status the embedder wants visible across many frames (e.g. "reconnecting
to live session"), not a one-shot acknowledgment. Repaints the footer
before returning.
Sets (or clears, with nil) the status strip's :stall_verdict seam
(Raxol.Harness.StatusStrip's own documented integration point) and
repaints the footer. The strip already renders the ALERT: <evidence>
segment for a :stalled/:looping verdict with non-empty evidence; this
function is only the model-side plumbing that gets a verdict into
model.status in the first place.
@spec resize(t(), pos_integer(), pos_integer()) :: t()
Resizes the geometry. Composes InlineAuthority.resize/3 |> InlineAuthority.keyframe/2 explicitly (the documented composition --
resize/3 alone never repaints the footer) in inline/tmux modes;
FlatAuthority.resize/3 writes zero bytes either way.
If an overlay is open and the NEW geometry can no longer host it
(new_rows - 2 - model.footer_rows < OverlayPicker.height(picker)), the
overlay is force-closed FIRST, at the OLD geometry (restoring the base
footer pin), before the resize itself runs -- see the moduledoc's "The
overlay picker" section. If it still fits, it stays open: the grown
footer row count survives the resize (ScrollRegionManager.resize/2
holds footer_rows constant, and the overlay's grown claim IS the
current footer_rows as far as the authority is concerned), and the
keyframe below repaints it at the new position.
A diff expansion mirrors the same force-close discipline (same
max_overlay_rows/2 capacity check, at the OLD geometry, before
anything else runs), but does NOT simply stay open unchanged when it
still fits: because the expansion mechanism is "claim the MAXIMUM
non-degenerate footer," not a fixed height like the overlay's, the claim
is RE-DERIVED at the new geometry every resize (max_overlay_rows(rows, model.footer_rows) again), the footer re-grown to match via
InlineAuthority.set_footer_rows/2, and DiffExpansion.resize_view/3
re-renders the SAME content at the new width/window (clamping its
scroll offset) -- see the moduledoc's "Full-screen diff expansion"
section. A resize_view/3 failure (degenerate target geometry) falls
back to closing the expansion and restoring the base pin, same as the
too-small force-close path.
Seals ONE honest, plain marker line into the history region at the current append point -- the loss-honesty marker for live streaming (e.g. shed deltas, a rejected/dropped event). This instrument never renders a gapless lie over lost data: when the live lane cannot deliver every event, this is how the transcript says so, instead of silently rendering as if nothing had been lost.
Uses the SAME emit paths seal_block/2 uses (FlatAuthority.seal/2 with
a trailing "\n" in :flat mode; InlineAuthority.seal/2 with a
trailing "\r\n" otherwise), through ViewText.lines/3 exactly like
every other sealed line. painted_count is deliberately NOT advanced --
a marker is not a block, and this module's fold/jump bookkeeping
(frontier_entries/1, paint_pending_blocks/1) only ever reasons about
model.projection.blocks.
@spec startup_push_up(IO.device(), pos_integer()) :: :ok
Startup discipline: push any existing dirty screen
content into scrollback via plain newlines, NEVER \e[2J (which would
wipe native scrollback on wezterm/kitty). Callers write this
BEFORE the substrate's scroll region is established (i.e. before
new/2), since InlineAuthority.new/5 only sets the DECSTBM split --
it never clears or pushes anything on its own.
@spec switch_session(t(), Raxol.Harness.Fixture.Session.t() | [map()]) :: t()
Switches the active session to session_or_events (see the moduledoc's
"The pickers" section for the print-once semantics this implements):
authority/composer/mode/geometry (width/rows)/footer_rows/
sessions_dir/editor_session/editor_opts are left untouched --
sealed history above is never touched by this function at all, which is
the whole point. Replay state resets (events, revealed,
projection, painted_count, fold_overrides, focused_index,
status) so the new session starts its own fresh reveal. Does NOT paint
by itself -- the caller's trailing paint_footer/1 (in
handle_input/2) covers the footer.
Advances the elapsed-since-last-event ticker (the status strip's
Stage slot) without revealing a new fixture event -- elapsed ticks
during a long silent tool call (the status strip's own acceptance).
Plain caller-supplied now, same no-wall-clock discipline as
advance/2.