Raxol.Harness.StatusStrip (Raxol v2.6.1)

View Source

Roadmap unit T10 (docs/proposals/in-flight/harness-ui-roadmap.md, "Construction -- chrome" section): the pinned status strip, as a pure projection state -> [footer_line].

Per the T2c footer contract (Raxol.UI.Rendering.PaintAuthority's repaint_footer/2 and keyframe_footer/2 -- both take iodata(), no width parameter of their own): callers hand the paint authority already width-truncated text. This module is that seam for the strip specifically -- it does no terminal I/O and knows nothing about cursor positioning; it only turns a plain state map into a list of plain strings, each guaranteed to fit the given display width (measured via Raxol.UI.TextMeasure, never String.length -- see truncate_to_width/2).

Why this isn't built on the existing Component tree

Raxol.UI.Components.Harness.StatusBar (merged #540) and Raxol.UI.Components.Display.StatusBar it delegates to both return view maps (%{type: :row, children: [...]}) for the normal Preparer -> LayoutEngine -> UIRenderer pipeline. The T2c footer path bypasses that pipeline entirely (byte-level pinned-region writes), so running those components just to string-scrape their children would add a dependency on internal view-map shape for no benefit. This module reuses their formatting conventions instead (the "Label: value" slot idiom, "#{round(pct)}%", "$" <> float_to_binary(..., decimals: 2)), so a strip fixture and a Harness.StatusBar fixture read the same way, without sharing a render path that serves a different contract.

It deliberately does NOT reuse Harness.ActivityIndicator's :working display either: that branch renders a bare animated spinner glyph with no elapsed number, which is exactly what roadmap pain point P4 rules out ("stage + elapsed, never a bare spinner"). This module borrows ActivityIndicator's shape -- a caller-accumulated elapsed value, never System.monotonic_time/1 inside render (see below) -- but always renders the numeric elapsed alongside the stage.

Why this doesn't consume Raxol.Harness.Projection directly

T7's Projection.t() struct (blocks, tail, fold_defaults, diagnostics, source_events, damaged) carries none of this unit's fields today: no context_pct, no running session cost, no turn_stage, no needs_input. The closest thing is Raxol.UI.Components.Harness.Block's outcome.cost (per-block, not session-total) and kind/seal (block-level, not turn-level). Per methodology R10 (scope fence): a pre-existing gap only becomes an owned unit when a mapped acceptance criterion depends on it, and T10's acceptance is "fixture drives all fields" -- it does not require a Projection-derivation helper. So this module takes a plain, caller-assembled state map; wiring a real Projection (plus agent-runtime cost/context signals that don't exist in code yet) into that shape is T13a's (assembly) job, not this unit's.

Fields, priority, and the convention

Every field always occupies its labelled slot; a field with no data renders the em dash in that slot instead of a coerced default (never 0%, never $0.00 for "we don't know") -- silent defaults would be indistinguishable from a real zero, which is exactly the "explicit meaning" the roadmap acceptance calls for.

slotkeysource field(s)
Inputneeds_inputneeds_input (true/false/absent)
Stageturn stageturn_stage + now/last_event_at
Ctxcontext %context_pct gated by turn_completed
Costsession costcost

One conditional notice sits outside the slot system: an optional :stall_verdict entry (the stall detector's one integration seam -- see Raxol.Harness.StallDetector and the private stall_notice/1) prepends an ALERT: <evidence> segment at the very highest priority when, and only when, the verdict is :stalled/:looping with a non-empty evidence summary.

Priority order, highest first (kept longest as width shrinks; see field_keys/0 and render/2): needs_input (safety: an agent waiting on approval must never silently disappear from a narrow terminal), then turn_stage (the core "is it alive" signal), then context_pct, then cost (dropped first -- least action-relevant of the four). Degradation drops whole "Label: value" segments from the low-priority end; when even the single highest-priority segment doesn't fit, that segment itself is truncated with an ellipsis (never producing a line wider than the requested width).

Ctx and the "never a stale %" rule

The roadmap acceptance calls out one failure mode by name: "missing data renders ... never a stale % from the prior turn." Rather than trust every caller to remember to clear context_pct between turns, this module gates it structurally: context_pct renders only when state.turn_completed === true; any other value (false, nil, or the key simply absent) renders regardless of whether a context_pct number is also present in state. A caller that forgets to flip turn_completed fails safe (shows , never a wrong number) instead of failing silent.

OPEN QUESTION for T13a (the producer decides, not this module): this gate assumes context_pct is a turn-boundary snapshot (usage as of the last completed turn). If T13a's producer turns out to emit a live mid-turn meter — arguably the more valuable semantic ("am I about to hit compaction?" matters most DURING a long turn) — then gating on turn_completed inverts the operator's need (Ctx: — for the whole active turn, a number only in the dead gap between turns). In that case relax this gate to a context_fresh-style liveness flag supplied by the producer; the "never a stale %" rule stays, only the freshness signal changes. Do NOT work around it by setting turn_completed: true mid-turn — that lies to the Ctx slot's documented meaning.

The elapsed ticker and R11 (no wall-clock in the default suite)

now and last_event_at are both plain caller-injected integers (matching Raxol.UI.Components.Harness.Block's own convention of deriving duration_ms from event ts fields, never a live clock). render/2 never calls System.monotonic_time/1 or any wall-clock function -- elapsed is now - last_event_at, a pure subtraction of two inputs the caller controls completely, which is what makes the ticker deterministically testable (advance now in a test, the display advances predictably) without a single sleep or timer. Either value absent yields a nil elapsed (renders for the ticker half of Stage, independent of whether turn_stage itself is present).

Escalation thresholds (warn_after_ms default 15_000, hung_after_ms default 60_000, both overridable per-call via state): under 15s is unremarkable ("still thinking"); 15s-60s is flagged with a trailing SLOW marker (an agent turn commonly runs a compile/test tool call in this range -- "slow but plausible"); 60s+ prefixes HUNG (the P4 concern: something that still claims to be working but has gone quiet long enough that a human should look). These are starting defaults, not derived from a measurement; adjust per-deployment via state.warn_after_ms / state.hung_after_ms.

Glyph width honesty (review fix, T10 FIX-NOW batch)

Every non-ASCII glyph this module can emit must measure exactly one display column per Raxol.UI.TextMeasure.display_width/1 (glyphs/0 lists them; see the regression test in the test suite). The original warn-threshold marker was the hourglass emoji U+23F3 (⏳): it carries Unicode Emoji_Presentation and real terminals commonly render it 2 columns wide, but Raxol.Terminal.CharacterHandling.wide_char?/1's range table has no entry below the Misc Symbols and Pictographs block (0x1F300+), so it measures U+23F3 as width 1 -- a pinned strip built on that measurement would silently overflow its own width guarantee for the entire 15s-60s "slow" window. Replaced with the ASCII word SLOW, which is width-honest by construction (every ASCII byte is single-cell). The true fix belongs upstream in Raxol.Terminal.CharacterHandling's width table (Emoji_Presentation coverage, not just East Asian Width); once that lands and wide_char?/1 correctly flags emoji-presentation glyphs as 2 columns, a fancier glyph may safely return here.

Summary

Functions

The default "gone quiet long enough that a human should look" threshold, in milliseconds. See default_warn_after_ms/0 for why this is public.

The default "slow but plausible" threshold for the Stage elapsed ticker, in milliseconds. Public so the stall detector's no-progress signal formalizes THIS heuristic instead of inventing a parallel constant -- the strip stays the single source of truth for what "quiet too long" means.

Canonical field order, highest priority (kept longest under width pressure) first. Exposed so tests can exercise every field by name without hardcoding the list a second time and risking drift.

Every non-ASCII glyph this module can emit into a rendered line (currently the missing-data em dash and the width-degradation ellipsis). Exposed so the width-honesty regression test can assert, per character, that Raxol.UI.TextMeasure.display_width/1 == 1 -- see the "Glyph width honesty" moduledoc section for why this matters (U+23F3 ⏳ measured 1 col but rendered 2 in real terminals).

Projects state into the status strip's footer lines for a pinned region width display columns wide. Always returns exactly one line (a single-element list) -- the list return type matches the general T2c footer-line contract (other chrome units may contribute more than one line to the pinned footer), not because this unit ever produces more than one.

Types

state()

@type state() :: %{
  optional(:context_pct) => number(),
  optional(:cost) => number(),
  optional(:turn_stage) => turn_stage(),
  optional(:needs_input) => boolean(),
  optional(:now) => integer(),
  optional(:last_event_at) => integer(),
  optional(:turn_completed) => boolean(),
  optional(:warn_after_ms) => pos_integer(),
  optional(:hung_after_ms) => pos_integer()
}

turn_stage()

@type turn_stage() :: atom() | String.t()

Functions

default_hung_after_ms()

@spec default_hung_after_ms() :: pos_integer()

The default "gone quiet long enough that a human should look" threshold, in milliseconds. See default_warn_after_ms/0 for why this is public.

default_warn_after_ms()

@spec default_warn_after_ms() :: pos_integer()

The default "slow but plausible" threshold for the Stage elapsed ticker, in milliseconds. Public so the stall detector's no-progress signal formalizes THIS heuristic instead of inventing a parallel constant -- the strip stays the single source of truth for what "quiet too long" means.

field_keys()

@spec field_keys() :: [atom()]

Canonical field order, highest priority (kept longest under width pressure) first. Exposed so tests can exercise every field by name without hardcoding the list a second time and risking drift.

glyphs()

@spec glyphs() :: [String.t()]

Every non-ASCII glyph this module can emit into a rendered line (currently the missing-data em dash and the width-degradation ellipsis). Exposed so the width-honesty regression test can assert, per character, that Raxol.UI.TextMeasure.display_width/1 == 1 -- see the "Glyph width honesty" moduledoc section for why this matters (U+23F3 ⏳ measured 1 col but rendered 2 in real terminals).

render(state, width)

@spec render(state(), non_neg_integer()) :: [String.t()]

Projects state into the status strip's footer lines for a pinned region width display columns wide. Always returns exactly one line (a single-element list) -- the list return type matches the general T2c footer-line contract (other chrome units may contribute more than one line to the pinned footer), not because this unit ever produces more than one.

Pure: identical state + width always produce an identical result. Never raises on missing keys -- every field independently degrades to "—" in its slot rather than crashing or defaulting to a misleadingly-valid-looking value.