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 :opaque for anything not in that set (forward-compat: an unrecognised kind renders safely instead of crashing).

  • raw_kind -- the kind exactly as given to from_events/3, kept even when normalised to :opaque so 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 :live until seal/1 is called on it (typically when the projection observes the block's owning turn/item close).

  • outcome -- %{exit_code:, duration_ms:, cost:}, each nil when not present in the source events.
  • content -- the kind-specific projection of the source events' payloads (never the raw events themselves; see the private extract_content/2 clauses 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 optional cross_turn_count, see BlockBuilder's moduledoc "Cross- turn disclosure") -- renders:
    1. a summary line, "N evidence refs: 2 tool results, 1 message" (n, pluralized, then counts -- [%{type:, count:}], already sorted descending by count -- joined ", ", each phrase pluralized by ITS OWN count), with a " (M cross-turn)" suffix appended when cross_turn_count (M) is present;
    2. up to length(entries) per-ref lines (entries is capped upstream, see BlockBuilder), 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 carries cross_turn: true (a ref the producer's own gate would never have accepted as same-turn evidence -- shown, not hidden);
    3. a trailing "+N more" line when n exceeds length(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.

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.

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.

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

Toggles fold state; dispatches to fold/2 or unfold/2.

Unfolds block. Same pre/post-seal semantics as fold/2.

Types

fold_after_seal_policy()

@type fold_after_seal_policy() :: :allow | :deny

fold_state()

@type fold_state() :: :expanded | :folded

kind()

@type kind() :: :message | :reasoning | :tool_call | :diff | :approval | :opaque

outcome()

@type outcome() :: %{
  exit_code: integer() | nil,
  duration_ms: non_neg_integer() | nil,
  cost: number() | nil
}

seal_state()

@type seal_state() :: :live | :sealed

t()

@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

completion_rows(block, fg \\ nil)

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

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.

default_fold(kind)

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

fold(block, opts \\ [])

@spec fold(
  t(),
  keyword()
) :: t()

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.

fold_allowed?(block, opts \\ [])

@spec fold_allowed?(
  t(),
  keyword()
) :: boolean()

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.

folded?(block)

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

from_events(kind, events, opts \\ [])

@spec from_events(term(), [map()], keyword()) :: t()

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 to default_fold(kind).
  • :seal -- initial seal state, defaults to :live.

known_kinds()

@spec known_kinds() :: [kind()]

The known block kinds (excludes :opaque, which is the forward-compat fallback, not a kind a caller asks for).

live?(block)

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

render(block, context \\ %{})

@spec render(t(), map()) :: map()

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.

seal(block)

@spec seal(t()) :: t()

Marks a block sealed. Idempotent.

sealed?(block)

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

search_text(block)

@spec search_text(t()) :: String.t()

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/1 shows only its first line).
  • :tool_call -- content.result (summary/1 already carries name + args).
  • :approval -- the FULL content.action (summary/1 only shows its first line) plus content.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 a to_string/1 call on an arbitrary term -- a named, honest limitation: a block whose options are atoms contributes no option text to the corpus.
  • :diff -- content.old and content.new (summary/1 already 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.

search_text(block, max_graphemes)

@spec search_text(t(), pos_integer() | :infinity) :: String.t()

summary(block)

@spec summary(t()) :: 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).

toggle_fold(block, opts \\ [])

@spec toggle_fold(
  t(),
  keyword()
) :: t()

Toggles fold state; dispatches to fold/2 or unfold/2.

unfold(block, opts \\ [])

@spec unfold(
  t(),
  keyword()
) :: t()

Unfolds block. Same pre/post-seal semantics as fold/2.