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.
| slot | key | source field(s) |
|---|---|---|
Input | needs_input | needs_input (true/false/absent) |
Stage | turn stage | turn_stage + now/last_event_at |
Ctx | context % | context_pct gated by turn_completed |
Cost | session cost | cost |
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
@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() }
Functions
@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.
@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.
@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.
@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).
@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.