Raxol.Harness.PanelProjection (Raxol v2.6.1)

View Source

Pure read-model fold over extract meta events, for the harness's worktracks/memory/plan overlay panels.

Frozen contract vs. contract-shape assumption

This module builds against the frozen meta-event contract shapes: the extract entry in Raxol.Agent.Meta.Registry (required payload keys [:class, :op, :item, :refs], scope :session), and against contract- shape fixtures authored to that registry (test/fixtures/harness/sessions/projection-panels.jsonl). The registry entry is load-bearing and settled. The per-class item field shapes this module reads (worktracks: id/lane/title/status; memory: key/value; plan: id/title/status) are contract-shape ASSUMPTIONS — they must be verified against real agent-emitted extract events before this unit's PR merges. Nothing here should be read as ground truth for the wire shape until that verification happens.

Tolerant reading

An unknown class, unknown op, unrecognized type, or a missing/ malformed field is skipped, never an error — a single unrecognized meta event must not take down the whole fold. This is deliberate: a malformed instance of a known shape found in a real journal (e.g. an extract whose op is "add" but whose item is a string) is a defect to report against the emitting codec, never something to silently work around inside this module. "Skip" here only ever means "this class/op/ shape isn't one I know how to read," not "I read it and it was wrong."

Recompute, not incrementally cached

The read-model is recomputed on demand -- at panel summon and on each footer repaint while a panel is open -- not incrementally maintained as events arrive. fold/2 and render_lines/2 are both O(events) per paint, matching the surface's existing per-advance full re-projection (see Raxol.Harness.Projection). "Dismissed is not dead": the fold source is the projection's retained durable events (Raxol.Harness.Projection.source_events/1, itself already durable-only), so re-summoning a panel folds current state without touching the block projection at all.

Clamps

Two independent clamps guard against hostile or runaway meta-event content (meta payloads are agent-produced and untrusted):

  • @max_field_bytes -- every string entering a read-model is passed through display_string/1 first: binaries longer than 512 bytes are clamped to at most 512 bytes (byte-sliced, then any trailing partial codepoint from a mid-codepoint split is dropped so the result stays valid UTF-8 -- this drops at most 3 bytes); non-binaries are rendered via inspect/2 with bounded :limit/:printable_limit, never evaluated or atomized. Control bytes are not stripped here -- sanitizing control bytes for actual terminal rendering is Raxol.Harness.Surface.ViewText's trust boundary, downstream of this module; stripping them here too would just duplicate that seam without adding safety.
  • @max_entries -- at most 500 distinct identities are retained per kind. A 501st distinct identity added evicts the oldest retained entry (first-seen order), bounding memory even against a runaway or adversarial extract stream.

Summary

Functions

Folds events (event-shaped maps or Raxol.Harness.Fixture.Event structs, top-level fields read via Map.get/2 -- works for both) into the kind read-model. Only family: :meta, type: :extract events with a matching class contribute; everything else is silently skipped (see moduledoc, "Tolerant reading"). Pure and deterministic: identical input always yields an identical read-model.

The three panel kinds this module folds.

Folds then formats kind's read-model into footer-row lines. Every line is newline-flattened (\r\n/\n/\r -> " ") so one read-model entry is always exactly one footer row -- the same discipline Raxol.UI.Harness.OverlayPicker applies to item labels, and load-bearing for the same reason: a raw embedded newline here would silently overflow a caller's fixed row budget. An empty read-model renders as ["(empty)"]. No width truncation happens here -- ViewText owns that, same as OverlayPicker.

Types

kind()

@type kind() :: :worktracks | :memory | :plan

memory_entry()

@type memory_entry() :: %{key: String.t(), value: String.t()}

plan_entry()

@type plan_entry() :: %{title: String.t(), status: String.t()}

worktracks_item()

@type worktracks_item() :: %{title: String.t(), status: String.t()}

worktracks_lane()

@type worktracks_lane() :: %{name: String.t(), items: [worktracks_item()]}

Functions

fold(kind, events)

@spec fold(kind(), [map()]) :: [worktracks_lane()] | [memory_entry()] | [plan_entry()]

Folds events (event-shaped maps or Raxol.Harness.Fixture.Event structs, top-level fields read via Map.get/2 -- works for both) into the kind read-model. Only family: :meta, type: :extract events with a matching class contribute; everything else is silently skipped (see moduledoc, "Tolerant reading"). Pure and deterministic: identical input always yields an identical read-model.

kinds()

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

The three panel kinds this module folds.

render_lines(kind, events)

@spec render_lines(kind(), [map()]) :: [String.t()]

Folds then formats kind's read-model into footer-row lines. Every line is newline-flattened (\r\n/\n/\r -> " ") so one read-model entry is always exactly one footer row -- the same discipline Raxol.UI.Harness.OverlayPicker applies to item labels, and load-bearing for the same reason: a raw embedded newline here would silently overflow a caller's fixed row budget. An empty read-model renders as ["(empty)"]. No width truncation happens here -- ViewText owns that, same as OverlayPicker.