Files
community-plugins/claude-companion/sessions.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

172 lines
6.9 KiB
Luau

-- The sessions panel — per-session detail and actuation for every live Claude
-- session. Right-click the bar pulse to open it (onClick keeps the answer panel).
--
-- The bar tooltip already lists concurrent sessions, but a tooltip cannot be acted
-- on: it vanishes on the way to it. This panel is the actionable form of the same
-- rollup — one row per session with its state, model and token burn, plus a retire
-- control for the one documented failure mode: a session that ended without its
-- SessionEnd hook firing (terminal killed, hook interrupted mid-distill) sits at
-- idle forever and keeps inflating the count. Retiring it is a local correction
-- that needs no terminal.
--
-- Pure subscriber, same doctrine as the orb and the answer panel: pulse-svc owns the
-- session table, this panel only renders it and speaks the documented IPC vocabulary
-- back. It adds NO new protocol — a retire is the ordinary `session_end` event with
-- a session-tagged payload, exactly what hooks/pulse.py sends (see PROTOCOL.md).
local KEY = "claude.pulse"
local SVC = "lowcache/claude-companion:pulse-svc"
local function tr(key, args) return noctalia.tr(key, args) end
-- Panel surface is 440 logical px (plugin.toml); leave room for the scrollbar.
local WRAP = 388
local PAD = 14
local ERROR_RGB = "#FF4D1F"
-- Compact per-session words, same vocabulary as the bar tooltip.
local STATE_WORD = {
idle = "state.word.idle", turn_start = "state.word.turn_start", text = "state.word.text",
tool_start = "state.word.tool_start", needs_attention = "state.word.needs_attention",
turn_end = "state.word.turn_end", error = "state.word.error",
}
local last = nil -- fingerprint of the last render (an unconditional one would reset scroll)
local function kfmt(n)
n = tonumber(n) or 0
if n >= 1000000 then return string.format("%.1fM", n / 1000000) end
if n >= 1000 then return string.format("%.1fk", n / 1000) end
return tostring(n)
end
-- A session id reaches a shell command below, so it is allowlisted first. Session
-- ids come from our own service, but the shim's injection review (a caller-supplied
-- id concatenated into a command language) is the same shape of risk, so the same
-- discipline applies here: anything outside [A-Za-z0-9._-] is refused rather than
-- quoted, and a refused row simply renders without its retire control.
local function safe_sid(sid)
return type(sid) == "string" and sid ~= "" and sid:match("^[%w%._%-]+$") ~= nil
end
-- Retire one session by speaking the ordinary protocol: session_end carrying a
-- payload whose only populated field is the trailing session id. parse_payload in
-- pulse-svc reads field 6, so ",,,,,<sid>" is a well-formed session-tagged event
-- with no telemetry — no new IPC verb, no service change.
local function retire(sid)
if not safe_sid(sid) then return end
noctalia.runAsync("noctalia msg plugin '" .. SVC .. "' all session_end ',,,,," .. sid .. "'")
end
local function session_row(s)
local word = STATE_WORD[s.state] and tr(STATE_WORD[s.state]) or tostring(s.state)
local line = tostring(s.sid) .. " · " .. word
if s.model and s.model ~= "?" and ((s.tin or 0) + (s.tout or 0)) > 0 then
line = line .. " · " .. s.model .. " " .. kfmt(s.tin) .. "/" .. kfmt(s.tout)
end
if (s.cr or 0) > 0 then
line = line .. " · " .. tr("pulse.cached", { n = kfmt(s.cr) })
end
local cells = {
ui.label({
text = line,
maxWidth = WRAP - 92,
maxLines = 2,
color = s.state == "error" and ERROR_RGB or nil,
flexGrow = 1,
}),
}
-- The retire control is omitted rather than disabled for a non-conforming id:
-- a button that cannot act is worse than no button.
if safe_sid(s.sid) then
local sid = s.sid -- captured per row; the handler table is rebuilt each render
cells[#cells + 1] = ui.button({
text = tr("sessions.retire"),
variant = "secondary",
onClick = function() retire(sid) end,
})
end
return ui.row({ gap = 8, align = "center" }, cells)
end
local function render(snap)
local list = (type(snap) == "table" and type(snap.sessions) == "table") and snap.sessions or {}
local count = #list
-- Separate singular/plural keys rather than a "1 sessions" fudge: the count is
-- prominent in the header, so the disagreement reads as a bug.
local title = count == 1 and tr("sessions.title_one") or tr("sessions.title", { count = count })
local rows = { ui.label({ text = title, fontWeight = "bold" }) }
local body
if count == 0 then
body = ui.column({ gap = 6 }, {
ui.label({ text = tr("sessions.empty"), maxWidth = WRAP, opacity = 0.7 }),
})
else
local items = {}
local Tin, Tout = 0, 0
for i, s in ipairs(list) do
if i > 1 then items[#items + 1] = ui.separator({}) end
items[#items + 1] = session_row(s)
Tin = Tin + (tonumber(s.tin) or 0)
Tout = Tout + (tonumber(s.tout) or 0)
end
if (Tin + Tout) > 0 then
items[#items + 1] = ui.separator({})
items[#items + 1] = ui.label({
text = tr("pulse.total", { tin = kfmt(Tin), tout = kfmt(Tout) }),
opacity = 0.7,
})
end
body = ui.column({ gap = 6 }, items)
end
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.scroll({ flexGrow = 1 }, { body })
-- flexGrow on the root column is load-bearing for the same reason as the answer
-- panel: without it the column takes its full-content height and the panel clips
-- instead of the scroll child being bounded.
--
-- Deliberately UNFILLED, same as answer.luau. A decorated panel is inset by the
-- host (panel_manager.cpp: `hasDecoration ? contentScale * Style::panelPadding`),
-- and no [[panel]] manifest key opts out of decoration — so any fill on the root
-- is painted INSIDE that inset and leaves the host's panel colour as a rim. The
-- host background is the themed panel surface; letting it through is what makes
-- this panel read as part of the shell instead of a card floating in a frame.
-- Contrast comes from the tinted controls, not from repainting the backdrop.
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1 }, rows))
end
-- Fingerprint the rendered fields only, so a re-render happens on a real change and
-- not on every tick (which would reset the scroll position mid-read).
local function fingerprint(snap)
if type(snap) ~= "table" or type(snap.sessions) ~= "table" then return "" end
local parts = {}
for i, s in ipairs(snap.sessions) do
parts[i] = table.concat({
tostring(s.sid), tostring(s.state), tostring(s.model),
tostring(s.tin), tostring(s.tout), tostring(s.cr),
}, "\2")
end
return table.concat(parts, "\1")
end
function onOpen(_context)
panel.setWantsSecondTicks(true) -- host stops ticks on close, re-arms on reopen
local snap = noctalia.state.get and noctalia.state.get(KEY)
last = fingerprint(snap)
render(snap)
end
function update()
local snap = noctalia.state.get and noctalia.state.get(KEY)
local fp = fingerprint(snap)
if fp ~= last then
last = fp
render(snap)
end
end