Raxol.UI.Components.Harness.BodyProvider (Raxol v2.6.1)

View Source

The T5 seam: an explicit per-kind content-map contract, plus the mapping from a %Raxol.UI.Components.Harness.Block{}'s kind to the merged component (or pair of components) that renders its EXPANDED body.

Per docs/proposals/in-flight/harness-ui-STATE.md's binding advisory note, this contract is defined BEFORE mounting: Block.content (T4) is a plain, kind-shaped map (see Block's moduledoc, "Rendering"), and every known kind's shape is documented here as the schema a body map must satisfy to mount safely. Raxol.UI.Components.Harness.BlockBody (T5's fold-aware entry point) is the only caller in production code; this module is also directly test-facing so the schema and the kind-to-component mapping are independently exercisable.

Per-kind content-map schema

Required keys (validated by validate/2; optional keys are read with a safe default and never fail validation):

  • :message -- required :text (the Markdown/plain body); optional :role (:user | :assistant, defaults to :assistant -- Block's own extraction never populates :role today, a documented gap, not a bug: a future projection unit can add it, contract-only-grows).
  • :reasoning -- required :text.
  • :tool_call -- required :name, :args; optional :result (nil while the call has no paired result yet) and :tainted (boolean, defaults false). Mirrors exactly what Block.extract_content(:tool_call, ...) already produces.
  • :diff -- required :path, :old, :new; optional :language. Mirrors Raxol.UI.Components.Harness.DiffViewer's own prop names -- see Block's extract_diff_content/1 (T5 extension: no prior producer resolved :diff kind, so there was no existing shape to preserve).
  • :approval -- required :action, :blast_radius, :options. :blast_radius must be shaped like Raxol.UI.Components.Harness.BlastRadiusPreview.blast_radius(), or nil when no producer ever supplied one -- nil is a real, distinct value here, not a validation failure: BlastRadiusPreview renders it as an explicit "not declared, treat as unsafe" warning, never as its %{} "No tracked effects." line (a producer-declared empty blast radius is a different, calmer claim than one nobody declared at all). :options must be shaped like Raxol.UI.Components.Harness.ApprovalPrompt.option() for the mounted render to be meaningful -- validate/2 only checks key PRESENCE (a cheap, always-safe check), not the value's inner shape; a wrongly-shaped value is the producer's bug, not something this seam can catch without becoming a second type-checker for every component's props.

:opaque (Block's forward-compat fallback for an unrecognised kind) has no schema and no mountable component -- component_for/1 and mount/2 both refuse it; callers fall back to Block.render/2's own opaque render, same as BlockBody does for any other mount failure.

Fold-aware sizing

This module only knows how to render the EXPANDED body. Folded rendering (one-line summary + outcome row) is Block.render/2's own proven code path -- BlockBody reuses it directly rather than reimplementing a second summary renderer here.

:reasoning stays plain through this path, by design

Unlike :message, a :reasoning body is never threaded through Harness.MarkdownBody here -- ReasoningBlock renders dim plain text on purpose (reasoning is meant to read as quieter, secondary content). Styled Markdown reasoning is an explicit opt-in that lives entirely in Block.render/2's own context[:markdown] path, not in this seam.

Summary

Functions

The canonical merged component for kind's expanded body. :tool_call composes a second component (ToolResultBlock, only when a result is present) internally in mount/2 -- this always names the primary one.

Every kind this seam has a schema + mountable component for (excludes :opaque).

Mounts body (a kind-shaped content map) through its real component, returning the rendered view map ({:ok, view}), or {:error, reason} when

The content-map keys kind requires. An unknown/:opaque kind has no schema -- an empty list, since component_for/1 refuses it separately and validate/2 on it always returns :ok (nothing to check, nothing to mount).

Validates body's keys against kind's schema. Presence-only (never inspects a value's shape -- see the moduledoc's :approval note): a content map that has every required key always passes, regardless of what those keys hold. Missing keys are named explicitly, in schema order, never a bare "invalid".

Types

body()

@type body() :: map()

kind()

Functions

component_for(kind)

@spec component_for(kind() | term()) :: {:ok, module()} | {:error, String.t()}

The canonical merged component for kind's expanded body. :tool_call composes a second component (ToolResultBlock, only when a result is present) internally in mount/2 -- this always names the primary one.

known_kinds()

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

Every kind this seam has a schema + mountable component for (excludes :opaque).

mount(kind, body, opts \\ [])

@spec mount(kind() | term(), body(), keyword()) :: {:ok, map()} | {:error, String.t()}

Mounts body (a kind-shaped content map) through its real component, returning the rendered view map ({:ok, view}), or {:error, reason} when:

  • kind has no known component (:opaque, or anything else outside known_kinds/0);
  • opts[:component] is given and disagrees with component_for/1 -- refuses rather than silently rendering kind's content through the wrong component (the "wrong-kind mount" guard: a caller asking to render a :diff body through ApprovalPrompt is a programming error, not a fallback case);
  • body fails validate/2.

Options

  • :context -- render context passed to every mounted component's render/2 (default %{}); :width inside it sizes :message, :reasoning, and :diff bodies.
  • :seal -- :live | :sealed (default :sealed), the block's own seal state. Threaded only into the :message body, as MessageBlock's render :mode (:live -> :streaming, :sealed -> :sealed) -- a live message renders with the provisional-close streaming treatment, a sealed one as a plain full parse. Defaulting to :sealed means existing direct callers of mount/3 see zero behavior change; BlockBody is the one caller that passes the block's real seal. Every other kind accepts and ignores it.

  • :outcome -- a Block.outcome() map (default %{}), consulted only for :tool_call to derive the mounted status glyph (:pending with no result yet, :failed on a non-zero exit_code, :done otherwise) -- Block.content itself carries no status field of its own.
  • :component -- override the component actually mounted (for testing the wrong-kind refusal above); defaults to component_for(kind).

What {:ok, _} | {:error, _} does NOT cover

The two-tuple result above covers this seam's OWN guard failures (unknown kind, wrong-kind override, schema validation) -- it does not catch an exception raised inside the mounted component's own init/1 or render/2 (a schema-valid but wrong-SHAPED prop reaching a component's internal guard/pattern match, see the :approval schema note above). This module keeps no try/rescue of its own, by design -- see Raxol.UI.Components.Harness.BlockBody's moduledoc ("the rescue lives HERE, not inside BodyProvider.mount/3"). Production code never calls mount/3 directly for exactly that reason: only BlockBody.render/2 does, and it wraps the call so a component raise recovers to the same {:error, reason} shape this function returns for its own failures. A caller that reaches mount/3 directly (as this module's own tests do) gets an uncaught raise on that path, not an {:error, _} tuple.

required_keys(kind)

@spec required_keys(kind() | term()) :: [atom()]

The content-map keys kind requires. An unknown/:opaque kind has no schema -- an empty list, since component_for/1 refuses it separately and validate/2 on it always returns :ok (nothing to check, nothing to mount).

validate(kind, body)

@spec validate(kind() | term(), body()) :: :ok | {:error, String.t()}

Validates body's keys against kind's schema. Presence-only (never inspects a value's shape -- see the moduledoc's :approval note): a content map that has every required key always passes, regardless of what those keys hold. Missing keys are named explicitly, in schema order, never a bare "invalid".