Raxol. UI. Components. Harness. Block
(Raxol v2.6.1)
View Source
The projection unit of the harness transcript: one sealed-or-live chunk of a session, folded from journal events into a renderable shape.
See docs/proposals/in-flight/harness-ui-roadmap.md (unit T4) and
docs/proposals/in-flight/harness-spec-protocol.md (the event contract
this module folds). The protocol's %Event{} struct does not exist in
code yet (spec draft only) -- from_events/3 accepts plain maps shaped
like it: %{id:, turn_id:, ts:, family:, type:, tier:, scope:, provenance:, payload:}, all keys optional and read defensively. That
tolerance is deliberate: the contract only grows, and this module must
never crash when it meets a field it doesn't know yet.
Struct
kind-- one of:message | :reasoning | :tool_call | :diff | :approval, or:opaquefor anything not in that set (forward-compat: an unrecognised kind renders safely instead of crashing).raw_kind-- the kind exactly as given tofrom_events/3, kept even when normalised to:opaqueso the opaque render can still show a meaningful label.event_refs-- the journal event ids this block was folded from.fold--:expanded | :folded.seal--:live | :sealed. A block is:liveuntilseal/1is called on it (typically when the projection observes the block's owning turn/item close).outcome--%{exit_code:, duration_ms:, cost:}, eachnilwhen not present in the source events.content-- the kind-specific projection of the source events' payloads (never the raw events themselves; see the privateextract_content/2clauses for the per-kind shape).
Purity
from_events/3 is a pure function: identical kind + events + opts
always produce an identical %Block{} (no random ids, no timestamps
generated here -- duration_ms is derived only from ts fields already
present on the source events).
Fold semantics and the D-PA gate
Fold state mutates freely while a block is :live. Once seal/1 marks it
:sealed, whether fold/2 / unfold/2 may still change the fold state is
the paint-authority decision (D-PA, harness-ui-roadmap.md sec 0) --
undecided as of this unit. Rather than hardcode a guess, every fold
transition takes a :fold_after_seal option (:allow | :deny, default
:deny) so the eventual D-PA verdict plugs in as a caller-supplied policy
with no rewrite of this module: pass fold_after_seal: :allow once D-PA
chooses soft-owned history or live-region-only with a wider live window;
leave the default :deny for seal-time-only fold semantics.
A denied post-seal fold is a silent no-op by design (fold/2 always
returns t(), never a tagged tuple). Callers that track fold state on
their side (T9 toggle sites, keybind handlers) must consult
fold_allowed?/2 before toggling, so their bookkeeping never desyncs
from a no-op transition.
Algebraically: fold/2 and unfold/2 are projectors (idempotent --
folding an already-folded block is a no-op), not involutions;
toggle_fold/2 is the involution, pre-seal. seal/1 is monotonic and
one-way by design -- there is no unseal. Every transition touches only
its own field: content, outcome, event_refs, kind, and
raw_kind are frozen at construction and never mutated afterwards.
Observability
Both total-safety rescues (construction fallback to :opaque, render
fallback to the placeholder line) are observable per
harness-ui-testing/06-projection.md sec 4: each emits a
Logger.warning/1 and the telemetry event
[:raxol, :harness, :block, :recovered] with metadata
%{kind:, reason:} -- a recovery is never silent. (Distinct from
Raxol.Harness.Projection.Recovery's stream-level
[:raxol, :harness, :projection, :recovered], whose metadata is
%{reason:, event_id:}.)
Rendering
render/2 returns a plain view map (%{type: :column, ...}), no
interactive Base.Component behaviour -- this unit renders plain text
bodies only. T5 mounts the rich per-kind components (already merged:
Harness.MessageBlock, Harness.ReasoningBlock, Harness.ToolCallBlock,
Harness.ToolResultBlock, Harness.DiffViewer, Harness.ApprovalPrompt,
...) as the fold-aware block bodies; this module is the data + text-only
fallback layer underneath that.
Expanded render = header line (fold glyph + kind glyph + first-line
summary) + full content lines + an outcome row + a completion row.
Folded render = the header line alone + the outcome row + a completion
row. The outcome row is omitted entirely when exit_code, duration_ms,
and cost are all nil; otherwise it renders only the fields that are
present.
The completion row (design creed: evidence, never a success toast)
content[:completion] -- set by Raxol.Harness.Projection.BlockBuilder. build_turn/3 on a turn's LAST block, only when that turn closed with a
final turn_completed -- is either:
%{evidence: :none}-- no accepted refs: renders ONE line, the LITERAL text"no evidence provided", no glyph, no checkmark -- the absence is information, never blank.%{evidence: entries, total: n, type_counts: counts}(plus an optionalcross_turn_count, seeBlockBuilder's moduledoc "Cross- turn disclosure") -- renders:- a summary line,
"N evidence refs: 2 tool results, 1 message"(n, pluralized, thencounts--[%{type:, count:}], already sorted descending by count -- joined", ", each phrase pluralized by ITS OWN count), with a" (M cross-turn)"suffix appended whencross_turn_count(M) is present; - up to
length(entries)per-ref lines (entriesis capped upstream, seeBlockBuilder), each"· " <> label(the label already sanitized/clamped -- an unresolvable ref's label is the literal"unresolvable evidence ref", rendered exactly like any other entry, never dropped), with a trailing literal" [cross-turn]"when that entry carriescross_turn: true(a ref the producer's own gate would never have accepted as same-turn evidence -- shown, not hidden); - a trailing
"+N more"line whennexceedslength(entries), itself suffixed" (M cross-turn)"when M of the hidden (never-rendered) refs are cross-turn -- so the summary's session-wide cross-turn tally is never left pointing at zero visibly marked lines.
- a summary line,
Key absent or unrecognised shape renders no row at all -- byte-identical
to a block that never went through the completion-evidence fold.
completion_rows/2 is Block's own render helper for this row, public
so Raxol.UI.Components.Harness.BlockBody's :expanded mount path
(which otherwise bypasses this module's body entirely) can append the
same rows after whatever real component it mounts -- see that module's
moduledoc. Every completion line is styled %{dim: true} and fades with
the same resolved prominence colour as the header/outcome rows.
Prominence
context[:prominence] (0.0..1.0) resolves the header/content/outcome
text colours through Raxol.UI.Harness.Prominence -- a salience solver
that fades a colour toward the background as prominence drops.
context[:ground] overrides the background lightness (default:
terminal-detected, see Raxol.UI.Theming.SalienceTheme.detect_ground/0).
context[:legibility_floor] (default false) is threaded through to
Prominence.resolve/3: the default is a pure fade (context text recedes,
becoming legible again as it is promoted); set it true for interactive
tiers where a minimum legibility must be preserved (see the Prominence
moduledoc's "Two modes").
A live :approval block auto-engages the needs-input starvation
guard (Prominence.resolve/3's needs_input: true): a pending
question is never faded below ordinary context content, whatever
prominence a demotion sweep hands it. A sealed approval is an answered
question and fades free. context[:needs_input] (boolean) overrides the
derivation in either direction -- flag any awaiting-input component in,
or an approval out.
When context[:markdown] is enabled, the Markdown body is faded to the
same resolved colour as the header, so the whole block dims together.
Default is neutral: when :prominence is absent from context, or
is 1.0, no style is touched -- the render is byte-identical to a render
without prominence (no :fg added to any style map), so existing callers
that never pass :prominence see zero change.
The colour is resolved once per render/2 call and threaded into every
branch, so a multi-line body never re-runs the solver per line.
Summary
Functions
Renders the completion-evidence row(s) for block.content[:completion]
(see the moduledoc's "The completion row" section) -- one line, styled
%{dim: true} faded to the same resolved fg the header/outcome rows
carry (nil when prominence is absent/neutral, matching every other
row's default). Public (unlike every other row helper in this module)
so Raxol.UI.Components.Harness.BlockBody's :expanded mount path can
append the same row after a mounted real component's own view -- that
module bypasses this render entirely once expanded, so the row would
otherwise silently vanish for every kind except the plain-text fallback.
The default fold state for kind (the "fold_defaults" a projection layer
like T7 assigns per identity sec 2 of harness-ui-testing/06-projection.md).
An unrecognised kind gets :opaque's default.
Folds block. Always allowed while :live. Once :sealed, gated by
opts[:fold_after_seal] (:allow | :deny, default :deny) -- see the
moduledoc's D-PA note. Denied post-seal folds are a no-op (the block is
returned unchanged), never an error.
Builds a %Block{} as a pure function of kind and its source events.
The known block kinds (excludes :opaque, which is the forward-compat
fallback, not a kind a caller asks for).
Renders block as a plain view map. context[:width] sets the wrap/
truncation budget (defaults to Raxol.Core.Defaults.terminal_width/0).
Marks a block sealed. Idempotent.
The honest per-block search corpus: "<kind> · <summary>" (the same
shape Raxol.Harness.Surface.open_jump_picker/1's labels already
use) followed by · plus the block's BODY text, when that body
carries content beyond what summary/1 already shows -- summary/1
only ever surfaces line 1 of message-shaped content, or the header
fields (name/args, path) for the other kinds.
One-line summary of block (kind-aware) -- the folded-header text and
the jump-picker's label source (see
command_palette_surface_test.exs's "jump picker" describe).
Unfolds block. Same pre/post-seal semantics as fold/2.
Types
@type fold_after_seal_policy() :: :allow | :deny
@type fold_state() :: :expanded | :folded
@type kind() :: :message | :reasoning | :tool_call | :diff | :approval | :opaque
@type outcome() :: %{ exit_code: integer() | nil, duration_ms: non_neg_integer() | nil, cost: number() | nil }
@type seal_state() :: :live | :sealed
@type t() :: %Raxol.UI.Components.Harness.Block{ content: map(), event_refs: [term()], fold: fold_state(), kind: kind(), outcome: outcome(), raw_kind: term(), seal: seal_state() }
Functions
Renders the completion-evidence row(s) for block.content[:completion]
(see the moduledoc's "The completion row" section) -- one line, styled
%{dim: true} faded to the same resolved fg the header/outcome rows
carry (nil when prominence is absent/neutral, matching every other
row's default). Public (unlike every other row helper in this module)
so Raxol.UI.Components.Harness.BlockBody's :expanded mount path can
append the same row after a mounted real component's own view -- that
module bypasses this render entirely once expanded, so the row would
otherwise silently vanish for every kind except the plain-text fallback.
Returns [] when the key is absent or its shape isn't recognised --
byte-identical to a render that never carries a :completion key at
all.
@spec default_fold(term()) :: fold_state()
The default fold state for kind (the "fold_defaults" a projection layer
like T7 assigns per identity sec 2 of harness-ui-testing/06-projection.md).
An unrecognised kind gets :opaque's default.
Folds block. Always allowed while :live. Once :sealed, gated by
opts[:fold_after_seal] (:allow | :deny, default :deny) -- see the
moduledoc's D-PA note. Denied post-seal folds are a no-op (the block is
returned unchanged), never an error.
Whether a fold/unfold transition on block would apply under the given
D-PA policy options -- the same :fold_after_seal option fold/2 and
unfold/2 take. Always true while :live; post-seal, true only
under fold_after_seal: :allow.
Interactive callers that keep their own fold bookkeeping (T9 toggle
sites) must check this before toggling, since a denied post-seal
fold/2 is a silent no-op.
Builds a %Block{} as a pure function of kind and its source events.
events is a list of maps shaped like the (not-yet-coded) protocol
%Event{} -- read defensively, every key optional. A kind outside
known_kinds/0 normalises to :opaque; raw_kind keeps the original
value for display. Never raises: any unexpected shape in events falls
back to an opaque block rather than crashing the caller.
Options
:fold-- initial fold state, defaults todefault_fold(kind).:seal-- initial seal state, defaults to:live.
@spec known_kinds() :: [kind()]
The known block kinds (excludes :opaque, which is the forward-compat
fallback, not a kind a caller asks for).
Renders block as a plain view map. context[:width] sets the wrap/
truncation budget (defaults to Raxol.Core.Defaults.terminal_width/0).
context[:markdown] (default false, additive/opt-in) routes a
:message/:reasoning block's text content through
Raxol.UI.Components.Harness.MarkdownBody instead of the plain
line-split body: :sealed mode while the block is :sealed, :streaming
(provisional-close) while it is still :live. Every other kind, and
every block when the option is omitted, renders exactly as before.
context[:prominence] (see the moduledoc's "Prominence" section) fades
the header, content, and outcome to one resolved colour. A Markdown body
fades in lockstep -- its text nodes carry the same colour as the header,
so a faded header never sits above a bright body.
Never raises: any unexpected internal shape falls back to a one-line placeholder rather than crashing the caller.
Marks a block sealed. Idempotent.
The honest per-block search corpus: "<kind> · <summary>" (the same
shape Raxol.Harness.Surface.open_jump_picker/1's labels already
use) followed by · plus the block's BODY text, when that body
carries content beyond what summary/1 already shows -- summary/1
only ever surfaces line 1 of message-shaped content, or the header
fields (name/args, path) for the other kinds.
Body per kind, read defensively from block.content (every field
may be missing or nil; a non-map content degrades to the
kind · summary prefix alone, same as a body that turns out empty):
:message,:reasoning,:opaque--content.text, the FULL text (summary/1shows only its first line).:tool_call--content.result(summary/1already carries name + args).:approval-- the FULLcontent.action(summary/1only shows its first line) pluscontent.options: a list whose BINARY entries are joined in. Non-binary entries (atoms, maps, anything else a producer might send) are skipped rather than risking ato_string/1call on an arbitrary term -- a named, honest limitation: a block whose options are atoms contributes no option text to the corpus.:diff--content.oldandcontent.new(summary/1already carries the path).
No sanitization happens here: Raxol.Harness.Surface.ViewText.lines/3
is the ONE trust boundary for control-byte stripping and display-width
truncation (see that module's moduledoc). Pure; never raises,
regardless of content's shape.
Bounding the work (max_graphemes)
search_text/1 returns the FULL corpus (max_graphemes: :infinity).
search_text/2 bounds it: the clamp is applied AT THE SOURCE -- every
body field is String.sliced to max_graphemes (which walks at most
max_graphemes graphemes and stops, never scanning the tail) BEFORE
it is concatenated or joined, and the assembled corpus is clamped once
more. So a caller on a synchronous input path (see
Raxol.Harness.Surface.open_search_picker/1) never pays O(body-size)
to build a bounded label out of an unbounded, untrusted body -- the
flatten/concat that used to run over the whole body now runs over at
most max_graphemes graphemes. The named, honest consequence: body
content past the cap is not part of the corpus (and so not
searchable), same as before -- but now the work, not just the
output, is bounded.
@spec search_text(t(), pos_integer() | :infinity) :: String.t()
One-line summary of block (kind-aware) -- the folded-header text and
the jump-picker's label source (see
command_palette_surface_test.exs's "jump picker" describe).
Unfolds block. Same pre/post-seal semantics as fold/2.