Files
community-plugins/claude-companion/orb.luau
T
d2aa9b9751 Update claude-companion to v1.3.0 (what its got: a lot) (#167)
* claude-companion: v1.3.0 — headless aggregator, user settings, sessions panel

Catalog-side update for lowcache/claude-companion, from 1.0.1 to 1.3.0.

Architecture: the pulse aggregator moved out of the bar widget into a headless
[[service]] (pulse-svc.luau). Capture no longer depends on the bar dot being
placed — the service starts with the shell and listens regardless of surfaces,
retiring the plugin's old "pulse must sit on a bar" deployment invariant. The bar
widget and desktop orb are now independent subscribers of the claude.pulse
rollup, rendering only. Noctalia 5 beta also fixed the older limitation where bar
widgets did not receive state.watch callbacks, so the bar dot is event-driven
like the orb; both docs are updated accordingly.

New: a `sessions` panel on right-click of the pulse (left-click still opens the
answer panel) — one row per live session with state, model and token burn, plus a
Retire control for a session whose SessionEnd hook never fired and which would
otherwise sit at idle indefinitely. It introduces no new IPC verb: only the
trailing `session` payload field is read for routing, so `session_end` with
`,,,,,<sid>` is already a well-formed single-session retire. PROTOCOL.md now
documents that property so any adapter can use it. Session ids are allowlisted
before reaching a shell command.

New: three animation settings (breath_speed, pulse_glow_floor, orb_swell), each
declaring an explicit `step` — an omitted step defaults to 1.0 in the manifest
parser, which collapses a fractional range to a couple of preset stops instead of
a slider.

plugin_api stays at 3. Everything used here is ungated at that level, and the
contributing guidance is to raise it only when adopting a capability from a newer
level. Verified against the installed build rather than assumed.

Also ships tests/manifest_spec.py, which pins the settings contract: every
numeric setting must declare an explicit step finer than its range, defaults must
land on a step boundary, label/description must use the *_key form, and every key
must resolve in translations/en.json. Neither the linter nor a widget spec can
catch a bad step, which is how the slider bug shipped in the first place.

Validated: catalog validator 54/54 exit 0, its own 54 self-tests pass, and the
plugin's suite (5 luau + shim + manifest) is green. Live-tested on niri against
Noctalia 5 beta; the compositor shim is unchanged in this update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* claude-companion: tint the sessions retire control, fix singular header

Follow-up on the v1.3.0 submission from live review: the retire button carried no
variant and rendered at the background colour; a single session read "1 sessions".
The panel root stays unfilled by design — noctalia panels are translucent under
the glass style, so the backdrop is the shell's, not the plugin's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: lowcache <drawpdeadredd@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:34:52 -04:00

149 lines
7.8 KiB
Luau

-- The presence orb (desktop widget) — the ambient "wow" half of the attention
-- pulse. Where the bar dot is a small static glyph, the orb is the SAME state icon
-- blown up on the desktop, magnifying in and out like a "bat signal": calm and slow
-- at idle, quick and insistent when Claude needs you.
--
-- It is a pure VIEW. pulse.luau already rolls all sessions up for the bar dot and
-- now mirrors that rollup into noctalia.state ("claude.pulse"); the orb just subscribes
-- and animates. No hook, IPC, or session bookkeeping lives here — one source of
-- truth (the bar pulse), two surfaces (bar dot + desktop orb).
--
-- Unlike the bar dot's DISCRETE glyph/color, the orb does PER-FRAME motion:
-- desktopWidget.setNeedsFrameTick(true) asks the host for onFrameTick(dt) callbacks
-- (dt = seconds since last frame); a raised-cosine "breath" drives the glyph's size
-- (the magnification) and opacity together. Breath speed/depth come from the state,
-- so the motion itself carries meaning. We keep ticking a slow idle breath even with
-- no sessions — ambient presence is the whole point of the orb.
--
-- API surface (verified against the noctalia 5.0.0 binary — no source ships; props
-- read from the ui reconciler's BoxProps/applyProps tables):
-- desktopWidget.render(tree) -- declarative ui.* tree
-- desktopWidget.setNeedsFrameTick(bool) -- opt into onFrameTick(dt)
-- ui.glyph{ name,size,color,opacity,width,height } ui.column/row ui.label{ text,
-- fontSize,color,maxWidth,maxLines,textAlign } noctalia.state.watch(key, cb)
-- NB: ui.box is a LEAF (RectNode) — it draws no children, its fill prop is `fill`
-- (not color/backgroundColor) and its soft glow is `softness`; there is no overlay
-- control, which is why the orb is a magnifying glyph rather than a glyph-on-disc.
-- Colors are the global scheme accents (secondary/primary/error) — same map as the
-- bar dot. See pulse.luau for the shared state→visual map.
-- Per-state look + motion. `color` is the accent the icon is drawn in; `glyph` is the
-- Tabler icon (same one the bar dot shows). `period` = seconds per full breath;
-- `omin`/`omax` = opacity bounds. Faster + deeper = more urgent. `word` labels it.
-- Slow, glowing breath — NOT a strobe. Periods are multi-second (a human breath is
-- ~4s), opacity floors stay high so the icon glows down rather than blinking to dark,
-- and the size swing (below) is gentle. Urgency reads as a *faster, deeper* breath,
-- but even "needs you" stays a breath, never a flicker.
local VISUAL = {
idle = { glyph = "robot", color = "secondary", period = 11.0, omin = 0.45, omax = 0.80, word = "state.orb.idle" },
turn_start = { glyph = "brain", color = "primary", period = 7.5, omin = 0.55, omax = 1.00, word = "state.orb.turn_start" },
text = { glyph = "message-dots", color = "primary", period = 7.0, omin = 0.58, omax = 1.00, word = "state.orb.text" },
tool_start = { glyph = "tool", color = "secondary", period = 7.5, omin = 0.55, omax = 1.00, word = "state.orb.tool_start" },
needs_attention = { glyph = "bell-ringing", color = "error", period = 4.5, omin = 0.55, omax = 1.00, word = "state.orb.needs_attention" },
turn_end = { glyph = "bell", color = "primary", period = 8.0, omin = 0.55, omax = 0.95, word = "state.orb.turn_end" },
error = { glyph = "alert-triangle", color = "error", period = 5.0, omin = 0.55, omax = 1.00, word = "state.orb.error" },
}
-- The orb IS the bar icon, swelling gently in and out — a desktop "bat signal". The
-- glyph size eases between MIN and MAX with the breath (a soft swell, not a zoom); a
-- fixed BOX reserves space so the swell never nudges the label below it.
local GLYPH_MIN = 70
local GLYPH_MAX = 75
local GLYPH_BOX = 84
-- Live status mirrored from the bar pulse. `count` 0 means no active sessions.
local cur = { state = "idle", count = 0, model = "?", tin = 0, tout = 0 }
local phase = 0.0 -- breath phase accumulator (seconds), wrapped per period
local breath_speed = 1.0 -- user setting (breath_speed): phase-rate multiplier
local orb_swell = 1.0 -- user setting (orb_swell): magnification swing multiplier
local frames = 0
local function tr(key, args) return noctalia.tr(key, args) end
-- Re-read user settings (cheap; called at load + every ~120 frames from onFrameTick).
local function read_settings()
local v = noctalia.getConfig and noctalia.getConfig("breath_speed")
if type(v) == "number" and v > 0 then breath_speed = v end
local sw = noctalia.getConfig and noctalia.getConfig("orb_swell")
if type(sw) == "number" and sw >= 0 then orb_swell = sw end
end
local function kfmt(n)
n = tonumber(n) or 0
if n >= 1e6 then return string.format("%.1fM", n / 1e6) end
if n >= 1000 then return string.format("%.1fk", n / 1000) end
return tostring(math.floor(n))
end
-- 0..1..0 over one period (raised cosine): 0 at phase 0, 1 at half period.
local function breath()
local v = VISUAL[cur.state] or VISUAL.idle
local s = 0.5 - 0.5 * math.cos((phase / v.period) * 2 * math.pi)
return v, s
end
-- One compact status line under the orb. Kept short so it doesn't get clipped by a
-- narrow widget box: just the state word, or a session count, plus burn when there's
-- a single attributable session.
local function subtitle(v)
if cur.count > 1 then
return tr("orb.sessions", { count = cur.count })
end
if cur.count == 1 and cur.model ~= "?" and (cur.tin + cur.tout) > 0 then
return tr(v.word) .. " · " .. kfmt(cur.tin) .. "/" .. kfmt(cur.tout)
end
return tr(v.word)
end
local function render()
local v, s = breath()
local opacity = v.omin + (v.omax - v.omin) * s
local size = GLYPH_MIN + (GLYPH_MAX - GLYPH_MIN) * s * orb_swell -- swing scaled by orb_swell
desktopWidget.render(ui.column({ gap = 6, padding = 12, align = "center" }, {
-- The "bat signal": the state icon itself, magnifying larger/smaller and breathing
-- opacity in the accent color — the same glyph the bar dot shows for this state.
-- A fixed width/height reserves the icon's footprint so the pulse doesn't shove
-- the label as it grows. (ui.glyph can't carry the box's soft `softness` glow, and
-- there's no overlay control, so the icon pulses crisp rather than haloed.)
ui.glyph({
name = v.glyph, size = size, color = v.color, opacity = opacity,
width = GLYPH_BOX, height = GLYPH_BOX,
}),
ui.label({
text = subtitle(v), fontSize = 12, color = "on_surface_variant",
maxWidth = 180, maxLines = 1, textAlign = "center",
}),
}))
end
-- Per-frame breath. `dt` is MILLISECONDS since the last frame (≈16.7 at 60 Hz, ≈6.9
-- at 144 Hz — verified empirically; the host passes real elapsed ms, so dividing by
-- 1000 makes `period` a true wall-clock second count, identical on every monitor).
-- Accumulate into `phase` and wrap at the current state's period so it can't grow
-- unbounded.
function onFrameTick(dt)
local v = VISUAL[cur.state] or VISUAL.idle
phase = (phase + (tonumber(dt) or 0) / 1000 * breath_speed) % v.period
frames = frames + 1
if frames % 30 == 0 then read_settings() end
render()
end
-- New status from the bar pulse. We don't reset `phase` on state change — the
-- breath glides into the new tempo instead of snapping, which reads calmer.
noctalia.state.watch("claude.pulse", function(snap)
if type(snap) ~= "table" then return end
cur.state = (type(snap.state) == "string" and VISUAL[snap.state]) and snap.state or "idle"
cur.count = tonumber(snap.count) or 0
cur.model = (type(snap.model) == "string" and snap.model ~= "") and snap.model or "?"
cur.tin = tonumber(snap.tin) or 0
cur.tout = tonumber(snap.tout) or 0
render()
end)
-- Breathe from the first frame, even before any session reports in (idle presence).
read_settings()
desktopWidget.setNeedsFrameTick(true)
render()