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/2 rewrites only footer rows whose content changed (a diff); keyframe/2 rewrites 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 by ModeSelect.select/3) -- the paint substrate. :tmux_conservative routes through the same InlineAuthority as :inline_log (per ModeSelect's own moduledoc: there is no separate TmuxConservativeAuthority), so this module only branches on :flat vs. everything else.
  • The block builder (Raxol.Harness.Projection) -- journal-fold: durable events become Blocks, item_delta traffic 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 flat iodata() 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.

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.

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.

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 (per Keymap's own moduledoc: "the assembly layer that already has the composer's buffer fills payload.text in 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.

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.

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.

t()

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

mode()

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

overlay()

@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.

t()

@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

advance(model, now \\ nil, opts \\ [])

@spec advance(t(), integer() | nil, keyword()) :: {t(), :ok | :done}

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 same adopt_resize/3 path resize/2 itself uses, minus that function's own immediate keyframe) BEFORE anything else in this call -- specifically, before any block seals this frame. resize/2 remains the standalone entry point; calling resize/2 then advance/2 is equally correct. The :resize option 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.

append_events(model, events)

@spec append_events(t(), [map()]) :: t()

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.

blur(model)

@spec blur(t()) :: t()

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).

close_expansion(model)

@spec close_expansion(t()) :: t()

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).

close_overlay(model)

@spec close_overlay(t()) :: t()

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.

close_stream(model)

@spec close_stream(t()) :: t()

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.

degenerate?(map)

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

done?(model)

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

expand_focused_diff(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.expansion is 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_index is nil.
  • {:error, :not_a_diff} -- the focused block does not exist, or its kind is 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 fails Raxol.UI.Components.Harness.BodyProvider's :diff schema (DiffExpansion.new/2's own validation, consulted BEFORE any row is claimed -- a content error never grows the footer).

flush_held(model)

@spec flush_held(t()) :: t()

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.

focus(model)

@spec focus(t()) :: t()

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".

focus_composer(model)

@spec focus_composer(t()) :: t()

Returns focus to the composer (the default -- see the moduledoc's precondition #3 note).

focus_transcript(model)

@spec focus_transcript(t()) :: t()

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.

frontier_entries(model)

@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 to block_sealed?/2, THE single-source committed-marker predicate (its doc states the painted_count comparison 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:

    1. A LIVE :approval block (Block.live?/1 with kind: :approval) is, per Block'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.
    2. 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.

frontier_scan(model)

@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.

handle_input(model, raw_event)

@spec handle_input(t(), term()) :: t()

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.

list_fixture_sessions(dir)

@spec list_fixture_sessions(Path.t()) :: [String.t()]

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.

new(session_or_events, opts)

@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 output IO.device().
  • :width, :rows (required) -- terminal geometry.
  • :footer_rows (default 6).
  • :env (default System.get_env/0) -- fed to ModeSelect.select_with_reason/3.
  • :tty? -- merged into :env as :tty? (default true).
  • :capabilities -- a %Raxol.Terminal.Capabilities{} or nil.
  • :fold_defaults -- forwarded to Projection.project/2.
  • :mode -- explicit override bypassing ModeSelect.select_with_reason/3 entirely (test seam). Bypasses the startup mode notice below too -- an explicit :mode is a test/caller decision, not a pick this module made, so there is no reason() to explain.
  • :editor_session -- nil (default), a module implementing Raxol.Harness.EditorSession's run(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); nil renders an honest stub notice instead. Embedders with a real tty pass Raxol.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 -- see Raxol.Harness.EditorSession's trust-boundary section). Model-owned device/rows/width always win over entries here.
  • :sessions_dir (default "test/fixtures/harness/sessions", the same source examples/harness_fixture_demo.exs reads) -- the directory list_fixture_sessions/1 (the session picker, s) lists .jsonl fixtures from.
  • :command_sink (default nil) -- a 1-arity fun that makes :interrupt/:steer LIVE instead of the fixture-mode stubs (see the moduledoc's "Command bifurcation" section). nil keeps today's honest stubs untouched; a fun receives %{type: :interrupt, payload: %{}} or %{type: :steer, payload: %{text: composer_text}} -- see Raxol.Harness.SessionLane for the seam a live implementation dispatches through on the other side.
  • :stream_open (default false) -- declares that more events may still arrive beyond whatever append_events/2 has delivered so far, so the reveal is never treated as finished merely for being momentarily caught up (see frontier_entries/1's fold-before-seal note). A live embedder sets this and calls close_stream/1 when 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.

open_command_palette(model)

@spec open_command_palette(t()) :: t()

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).

open_jump_picker(model)

@spec open_jump_picker(t()) :: t()

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.

open_overlay(model, items, opts \\ [])

@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 (see handle_input/2's :passthrough routing). Defaults to an honest one-frame footer notice ("» picked <label>"), the same stub mechanism :interrupt/:steer use.

Errors

  • {:error, :expansion_open} -- a diff expansion (see expand_focused_diff/1) is already claiming the footer; the two transient footer views never coexist.
  • {:error, :overlay_already_open} -- model.overlay is 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.

open_panel(model, kind, opts \\ [])

@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 (default OverlayPanel.default_max_visible/0) -- clamped to the available footer capacity, same narrowing rule open_overlay/3 applies (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.

open_search_picker(model)

@spec open_search_picker(t()) :: t()

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.

open_session_picker(model)

@spec open_session_picker(t()) :: t()

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.

put_lane_notice(model, text)

@spec put_lane_notice(t(), String.t() | [String.t()] | nil) :: t()

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.

put_stall_verdict(model, verdict)

@spec put_stall_verdict(t(), map() | nil) :: t()

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.

resize(model, width, rows)

@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.

seal_marker(model, text)

@spec seal_marker(t(), String.t()) :: t()

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.

startup_push_up(device, rows)

@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.

switch_session(model, session_or_events)

@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.

tick(model, now)

@spec tick(t(), integer()) :: t()

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.