diff --git a/claude-companion/PROTOCOL.md b/claude-companion/PROTOCOL.md index 5c70c09..ced38e9 100644 --- a/claude-companion/PROTOCOL.md +++ b/claude-companion/PROTOCOL.md @@ -1,8 +1,8 @@ # The Pulse Protocol -An agent-agnostic contract for driving the pulse bar widget (and everything -downstream of it: the presence orb, tooltips, `claude.pulse` subscribers). The -widget knows nothing about Claude Code — it consumes **events** and an optional +An agent-agnostic contract for driving the `pulse-svc` service aggregator (and everything +downstream of it: the pulse bar widget, the presence orb, tooltips, `claude.pulse` subscribers). The +service knows nothing about Claude Code — it consumes **events** and an optional **telemetry payload** over noctalia's plugin IPC. Any coding agent that can run a shell command on its lifecycle hooks (gemini-cli, codex, opencode, aider, a CI job, a cron script) can light up the same bar dot. @@ -20,9 +20,9 @@ Two adapters ship in `hooks/`: noctalia msg plugin all [payload] ``` -- `` is the plugin dispatch id: `:` — - `lowcache/claude-companion:pulse` for this install. Adapters must treat it as - configurable (`pulse-emit` reads `$PULSE_TARGET`). +- `` is the plugin dispatch id: `:` — + `lowcache/claude-companion:pulse-svc` (the headless aggregator service) for this + install. Adapters must treat it as configurable (`pulse-emit` reads `$PULSE_TARGET`). - `all` addresses every monitor's widget instance. (`focused` or a bare connector errors when the widget sits on multiple bars.) - `[payload]` is a **single positional token** — noctalia's msg CLI splits on @@ -48,8 +48,9 @@ active; "resting" matters for the default-slot rule below. | `session_end` | session is over — **retires** its slot | — | — | Unknown events render as idle-with-the-event-kept-as-state-word; stick to the -vocabulary. Glyph, accent color, and breath tempo are widget-side concerns -(see `VISUAL` in `pulse.luau`) — the protocol only fixes the *semantics*. +vocabulary. Glyph, accent color, and breath animation are widget-side concerns +(see `VISUAL` in `pulse.luau`, and the `breath_speed`, `pulse_glow_floor`, and +`orb_swell` user settings in `README.md`) — the protocol only fixes the *semantics*. ## Payload @@ -72,14 +73,19 @@ model,in,out,cacheCreate,cacheRead,session `?,0,0,0,0,`. State tracking, urgency priority, multi-session tooltip all work; you only lose the burn readout. -## Session semantics (what the widget guarantees) +## Session semantics (what the service guarantees) - One slot per `session` id; re-sending updates the slot in place. -- The bar renders the **most urgent** state across all live slots (priority - table above); the tooltip lists every session, most recent first, with a - Σ burn total. +- The service aggregates the **most urgent** state across all live slots (priority + table above) into `claude.pulse`; widgets render this rollup and the tooltip lists + every session, most recent first, with a Σ burn total. - `session_end` retires the slot. Nothing else does — a real session may sit at `idle` or `turn_end` indefinitely and stays listed. +- Because only the trailing `session` field is read for routing, a `session_end` + whose payload populates *only* that field is a well-formed retire for one + session and nothing else: `,,,,,`. The `sessions` panel's Retire + control emits exactly that, which is why manual retirement needs no new verb — + anything that can send `session_end` can already clear a stuck slot. - **Payload-less events** (no CSV at all — e.g. a manual `noctalia msg plugin … all needs_attention` poke from a terminal) land in a single shared `default` slot. To keep CLI pokes from leaving a phantom @@ -138,10 +144,11 @@ long_build && pulse-emit needs_attention ci # non-agent uses work too ## Downstream: the `claude.pulse` state mirror -The widget is the **single aggregator**; subscribers (the orb, or any future -surface) never parse events themselves. On every event — never from the -animation timer — it publishes a rollup snapshot to noctalia shared state under -`claude.pulse`: +The headless `pulse-svc` **service** is the **single aggregator**; subscribers (the +bar dot, the orb, or any future surface) never parse events themselves. On every +event — never from a timer — it publishes a rollup snapshot to noctalia shared state +under `claude.pulse` (top-level fields below, plus a `sessions` array of per-session +`{sid,state,model,tin,tout,cr}` for multi-session tooltips): ```lua { state = , -- "idle" when no sessions @@ -152,12 +159,16 @@ animation timer — it publishes a rollup snapshot to noctalia shared state unde cr = 1> } ``` -Desktop widgets receive it via `noctalia.state.watch("claude.pulse", cb)`; bar -widgets must poll `state.get` (watch doesn't fire on bars in noctalia 5.0.0). +Both desktop and bar widgets receive it via `noctalia.state.watch("claude.pulse", +cb)` — state.watch fires across all of a plugin's runtimes as of the Noctalia 5 beta +(the earlier "bars must poll" limitation is gone). -## Deployment invariant +## Deployment (retired invariant) -The aggregator lives in the `pulse` **bar widget** — bar widgets only run when -placed on a bar. If `pulse` isn't in a bar layout, every event is silently -dropped and all subscribers freeze. Noctalia 5.0.0 has no headless plugin -entry kind, so "pulse on a bar" is a hard install requirement. +The aggregator is the headless `pulse-svc` **`[[service]]`** — it starts at shell +launch and runs with no surface, so event capture never depends on any widget being +placed. (Historically the aggregator lived in the `pulse` bar widget: if that widget +wasn't on a bar, every event was silently dropped and all subscribers froze — the +**D10** fragility. Retired by the `[[service]]` entry kind added in the Noctalia 5 +beta; requires `plugin_api >= 3` on a service-capable build.) The bar dot and orb are +now pure subscribers of `claude.pulse`, so placing them is purely cosmetic. diff --git a/claude-companion/README.md b/claude-companion/README.md index 9243b73..5095aae 100644 --- a/claude-companion/README.md +++ b/claude-companion/README.md @@ -4,7 +4,7 @@ A Noctalia v5 plugin that puts [Claude Code](https://claude.com/claude-code)'s live status on your desktop — a **pulse** on the bar, a breathing **orb** on the desktop, and an **answer panel** for quick questions. -![version](https://img.shields.io/badge/version-1.0.0-blue) ![license](https://img.shields.io/badge/license-MIT-informational) ![noctalia](https://img.shields.io/badge/noctalia-5.0.0-blueviolet) +![version](https://img.shields.io/badge/version-1.3.0-blue) ![license](https://img.shields.io/badge/license-MIT-informational) ![noctalia](https://img.shields.io/badge/noctalia-5.0.0-blueviolet) Claude Code is a brilliant agent trapped in a text box. It can't see the windows you have open, can't tap you on the shoulder when it hits a wall, and gives you nothing to glance at while it churns. So you sit there watching a terminal, or you wander off and miss the moment it needed you. @@ -17,10 +17,10 @@ Don't run Claude Code? The signal bus is agent-agnostic — any agent, CI job, o | Field | Value | | --- | --- | | ID | `lowcache/claude-companion` | -| Entries | Bar widget: `pulse`; desktop widget: `orb`; panel: `answer`; launcher: `claude` | +| Entries | Service: `pulse-svc`; bar widget: `pulse`; desktop widget: `orb`; panels: `answer`, `sessions`; launcher: `claude` | | Launcher Prefix | `/claude` | -Built and live-tested against Noctalia 5.0.0 (build `623210223c`), with an offline spec suite (`tests/shim_spec.py`) pinning the shim's compositor seam and its injection guards. +Built and live-tested against Noctalia 5.0.0 (build `623210223c`), with an offline widget spec suite keeping the state machine honest. ## See it @@ -34,13 +34,13 @@ Ask something quick with `/claude ?` and the whole answer waits for you in the p ## How it works -**Perceive.** `shim/noctalia-mcp.py` is a stdio MCP shim that hands Claude a live read on your machine: your compositor's IPC for the windows you have open (it detects and speaks niri, Hyprland, or Sway), `playerctl` for what's playing, `noctalia msg status` for the state of the shell itself. Nothing to wire up by hand. Launch through `/claude` and it attaches itself. +**Perceive.** `shim/noctalia-mcp.py` is a stdio MCP shim that hands Claude a live read on your machine: `niri msg -j` for the windows you have open, `playerctl` for what's playing, `noctalia msg status` for the state of the shell itself. Nothing to wire up by hand. Launch through `/claude` and it attaches itself. **Practice.** Everything on the backend funnels through `claude.luau`, the `/claude` launcher and the one door in. It normalizes the event vocabulary, throws `notify-send` toasts, and calls `noctalia msg` to move panels around. One chokepoint on purpose — so when something acts up, there's exactly one place to go look. -**Pulse.** `pulse.luau` sits on your bar and runs the show. Hook events land here over IPC, and from there it does the rest: tracks every session at once, surfaces whichever one's most urgent, breathes in your accent color, and mirrors the rollup into `noctalia.state` under `claude.pulse` for anyone downstream to read. +**Pulse.** `pulse-svc.luau` is a headless `[[service]]` that runs the show. Hook events land here over IPC at `lowcache/claude-companion:pulse-svc`, and from there it does the rest: tracks every session at once, surfaces whichever one's most urgent, and publishes a rollup to shared state under `claude.pulse` for subscribers to read. -And downstream is where the quiet parts live. `orb.luau` is pure view. It subscribes to `claude.pulse` and breathes the same state frame by frame, glyph and opacity riding a sine wave, tempo picking up as things get urgent — no hooks, no logic of its own, just a reflection. `answer.luau` is the `answer` panel that catches a `/claude ?` reply and holds the whole thing: wrapped, scrollable, all the parts a toast lops off the end. +And downstream is where the surfaces live. `pulse.luau` on the bar and `orb.luau` on the desktop are independent subscribers that only render. Both watch `claude.pulse` — `pulse.luau` breathes the accent color and shows per-session tooltips, while `orb.luau` breathes the same state frame by frame, glyph and opacity riding a sine wave, tempo picking up as things get urgent. Neither holds hooks or logic of its own. `answer.luau` is the `answer` panel that catches a `/claude ?` reply and holds the whole thing: wrapped, scrollable, all the parts a toast lops off the end. ## Requirements @@ -62,7 +62,7 @@ noctalia msg plugins enable lowcache/claude-companion Then, in order: -1. **Put the `pulse` widget on a bar** (Settings → Bar). Read the warning below first — this one isn't optional. +1. **(Optional) Put the `pulse` widget on a bar** (Settings → Bar) for the glanceable dot — capture no longer depends on it; the headless `pulse-svc` service does the listening. 2. Add the `orb` desktop widget if you want the ambient presence. 3. Merge `hooks/settings.snippet.json` into `~/.claude/settings.json` so Claude's lifecycle hooks actually drive the pulse. 4. Point Claude at `shim/noctalia-mcp.py` with `--mcp-config` to hand it the senses and hands. (Sessions you launch through `/claude` do this for you.) @@ -70,16 +70,16 @@ Then, in order: Prove it works: ```sh -noctalia msg plugin lowcache/claude-companion:pulse all needs_attention # bar icon → red bell -noctalia msg plugin lowcache/claude-companion:pulse all idle # back to robot +noctalia msg plugin lowcache/claude-companion:pulse-svc all needs_attention # bar icon → red bell +noctalia msg plugin lowcache/claude-companion:pulse-svc all idle # back to robot ``` > [!WARNING] -> **`pulse` has to stay on a bar.** It's the sole aggregator — the one piece that hears the hooks and publishes the state everything else reads. Noctalia only runs bar widgets while they're placed on a bar, so the moment you pull `pulse` off, the plugin goes dark. The hooks keep firing into the void, the orb freezes on its last breath, and IPC pokes do nothing. If that happens, the fix is always the same: put `pulse` back on a bar. +> **`pulse` no longer has to stay on a bar.** The sole aggregator is now the headless `pulse-svc` service, which starts with the shell and listens whether or not any widget is placed — so pulling the `pulse` dot off a bar just hides the glanceable icon; the orb keeps updating and hooks/IPC still land. This retires the old **D10** requirement, made possible by the `[[service]]` entry kind added in the Noctalia 5 beta. ## Usage -`/claude ` opens a real Claude Code session in your terminal, shim already wired in. Bare `/claude` picks up where you left off (`claude --continue`). And `/claude ? ` is the quick one — a read-only ask that comes back as a toast and lands, in full, in the answer panel. Read-only is enforced, not assumed: the ask launches with no built-in tools, no MCP servers, and none of your Claude settings (so no hooks, plugins, or pre-authorized permissions), leaving it nothing but the model and your question. +`/claude ` opens a real Claude Code session in your terminal, shim already wired in. Bare `/claude` picks up where you left off (`claude --continue`). And `/claude ? ` is the quick one — a read-only ask that comes back as a toast and lands, in full, in the answer panel. That panel opens however you like it: click the pulse, use the "Show last answer" row under `/claude`, or toggle it from the CLI: @@ -91,6 +91,26 @@ Leave it open and it refreshes live while suppressing the toast, so you're never Hover the bar and the tooltip tells you where each session stands and what it's burning — input, output, cache reads. Run a few at once and you get a line per session plus a Σ total, with the icon always showing whichever one needs you most. +**Right-click the pulse** for the sessions panel — the same rollup, but you can act on it. A tooltip disappears on the way to it; this doesn't. One row per live session with its state, model and burn, and a **Retire** button on each. + +Retire is there for the one failure mode you'll actually hit: a session whose `SessionEnd` hook never fired — terminal killed, hook interrupted mid-distill — sits at idle forever and keeps inflating the count. Retiring it corrects the tally from the shell, without going back to a terminal. It adds no new protocol: a retire is the ordinary `session_end` event carrying that session's id, exactly what `hooks/pulse.py` sends. + +```sh +noctalia msg panel-toggle lowcache/claude-companion:sessions +``` + +## Settings + +The plugin declares three user settings, read via `noctalia.getConfig()`: + +| Setting | Type | Range | Step | Default | Description | +| --- | --- | --- | --- | --- | --- | +| `breath_speed` | double | 0.25–3.0 | 0.05 | 1.0 | Phase-rate multiplier for the breathing animation on both the bar dot and the desktop orb. Higher = faster. | +| `pulse_glow_floor` | double | 0.0–0.9 | 0.05 | 0.45 | How dim the bar dot gets at the trough of its breath. 0 = dims to black, higher = stays brighter. | +| `orb_swell` | double | 0.0–3.0 | 0.05 | 1.0 | How far the desktop orb glyph magnifies as it breathes. 0 = static size, higher = a bigger swing. | + +There are no color settings — both surfaces follow the active theme palette via accent role names (`secondary`, `primary`, `error`). + ## Wiring up other agents None of this is Claude-specific under the hood. The pulse speaks a plain event format and doesn't care who's talking — any agent, CI job, or shell script that can run a command on its own lifecycle can light up the same bar. [PROTOCOL.md](PROTOCOL.md) has the full eight-event vocabulary, the CSV payload, session semantics, and the adapter contract. The reference emitter, `hooks/pulse-emit`, is plain POSIX sh and needs nothing but `noctalia` on your PATH: @@ -106,7 +126,7 @@ hooks/pulse-emit session_end mysess A few things worth knowing before they surprise you: - Plugin panels render at `Layer::Top`, so an overlay window — a notification, a quake terminal, a polkit prompt — can sit on top of the answer panel. The answer's still there; clear the overlay and you'll see it. There's an upstream ask in for panel layer control. -- Bar widgets don't fire `state.watch` callbacks in Noctalia 5.0.0, so the plugin polls instead. Eight-digit hex alpha is ignored too — brightness is done by scaling RGB. +- Eight-digit hex alpha is ignored by bar widgets — brightness is done by scaling RGB toward black. (Earlier builds didn't fire `state.watch` on bars, so the pulse polled; the Noctalia 5 beta fires it, so the bar dot is now event-driven like the orb.) - Builtin and wallpaper-generated palettes have no on-disk JSON, so those fall back to fixed accent colors. Custom and community palettes are followed live, rechecked every ~8 s. - Quick-ask rides headless `claude -p`, which doesn't refresh an expired OAuth login token — only an interactive session does ([upstream](https://github.com/anthropics/claude-code/issues/53063)). The plugin checks the token's expiry before launching and, instead of burning the request on a guaranteed 401, tells you to open a terminal Claude session first; a failure it couldn't predict gets the same message in place of the raw API error. - The MCP shim is a Python prototype. A compiled port is the intended endgame. diff --git a/claude-companion/claude.luau b/claude-companion/claude.luau index 7c27292..78a94d5 100644 --- a/claude-companion/claude.luau +++ b/claude-companion/claude.luau @@ -156,7 +156,9 @@ end -- 401 whenever no terminal session has run recently. Two layers, both fail-open: -- a pre-flight that skips the launch when the token is positively expired, and -- an error-text match that swaps the raw API error for the remedy. --- (remedy text: tr("notify.auth_remedy")) +-- Resolved per call, not once at load: a language change should be reflected in the +-- remedy text without a plugin reload. +local function auth_remedy() return tr("notify.auth_remedy") end local CREDS_PATH = "~/.claude/.credentials.json" @@ -284,9 +286,8 @@ function onActivate(id) -- and the remedy needs a terminal anyway — so say so now and skip the -- launch. No claude.state touch: no ask session ever starts. if token_expired() then - local remedy = tr("notify.auth_remedy") - publish_answer(ask, remedy, true) - if not panel_showing() then noctalia.notifyError(tr("notify.title"), remedy) end + publish_answer(ask, auth_remedy(), true) + if not panel_showing() then noctalia.notifyError(tr("notify.title"), auth_remedy()) end return end set_state(EVENT.turn_start) @@ -316,7 +317,7 @@ function onActivate(id) if final == "" then final = tr("notify.ask_failed") end -- a 401 that slipped past the pre-flight: deliver the remedy, not the -- raw API error blob - if is_auth_error(final) then final = tr("notify.auth_remedy") end + if is_auth_error(final) then final = auth_remedy() end publish_answer(ask, final, true) if not panel_showing() then local body, clipped = preview(final) diff --git a/claude-companion/hooks/pulse-emit b/claude-companion/hooks/pulse-emit index 946ed19..99156d0 100755 --- a/claude-companion/hooks/pulse-emit +++ b/claude-companion/hooks/pulse-emit @@ -12,13 +12,13 @@ # Without one it sends a bare event, which lands in the widget's shared # "default" test slot — fine for a poke, wrong for a real integration. # -# Env: PULSE_TARGET plugin dispatch id (default lowcache/claude-companion:pulse) +# Env: PULSE_TARGET plugin dispatch id (default lowcache/claude-companion:pulse-svc) # PULSE_DRYRUN non-empty -> print the command instead of dispatching # # Fail-open by contract: exits 0 no matter what, output swallowed, dispatch # capped at 3 s. A hook must never block or error the agent driving it. -TARGET="${PULSE_TARGET:-lowcache/claude-companion:pulse}" +TARGET="${PULSE_TARGET:-lowcache/claude-companion:pulse-svc}" event="${1:-idle}" session="$2" diff --git a/claude-companion/hooks/pulse.py b/claude-companion/hooks/pulse.py index 76e7c20..cec0589 100755 --- a/claude-companion/hooks/pulse.py +++ b/claude-companion/hooks/pulse.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Pulse hook dispatcher (lowcache/claude-companion plugin). -Bridges a Claude Code lifecycle hook to the pulse bar widget, enriching the +Bridges a Claude Code lifecycle hook to the pulse aggregator service, enriching the event with live model + token-burn telemetry parsed from the session transcript. Invoked by the hooks in settings.snippet.json as: @@ -10,16 +10,16 @@ Invoked by the hooks in settings.snippet.json as: Hook JSON arrives on stdin (transcript_path, session_id). The widget is driven via noctalia's documented plugin IPC (`noctalia msg --help`): - noctalia msg plugin lowcache/claude-companion:pulse all [payload] + noctalia msg plugin lowcache/claude-companion:pulse-svc all [payload] `[payload]` is a single positional token, so the payload is a SPACE-FREE CSV the -widget (pulse.luau) parses: +aggregator service (pulse-svc.luau) parses: model,in,out,cacheCreate,cacheRead,session -The `session` (short id) tags EVERY event, so the widget can track each concurrent +The `session` (short id) tags EVERY event, so the service can track each concurrent session separately. The matching SessionEnd hook fires `session_end`, which retires -the session in the widget and drops its token cache here. +the session in the service and drops its token cache here. Token accounting is incremental: a per-session cache in $XDG_RUNTIME_DIR stores the last byte offset + running sums, so each hook reads only newly-appended transcript @@ -36,7 +36,7 @@ import os import subprocess import sys -PLUGIN = "lowcache/claude-companion:pulse" +PLUGIN = "lowcache/claude-companion:pulse-svc" TARGET = "all" diff --git a/claude-companion/hooks/settings.snippet.json b/claude-companion/hooks/settings.snippet.json index 556d232..11d1c33 100644 --- a/claude-companion/hooks/settings.snippet.json +++ b/claude-companion/hooks/settings.snippet.json @@ -1,5 +1,5 @@ { - "_comment": "Merge into ~/.claude/settings.json. The attention reflex: Claude Code lifecycle hooks invoke hooks/pulse.py , which reads the hook JSON on stdin, computes live model + token-burn telemetry from the session transcript, and dispatches into the pulse bar widget via `noctalia msg plugin lowcache/claude-companion:pulse all [payload]` (target `all` = every monitor's instance; `focused`/bare connector error when the widget is on multiple bars). payload is a space-free CSV `model,in,out,cacheCreate,cacheRead,session` whose trailing `session` (short id) tags every event, so the widget tracks each concurrent session separately and renders the most urgent state + a per-session token-burn tooltip. SessionEnd fires `session_end`, retiring that session in the widget and dropping its token cache. The dispatcher is fail-open: if noctalia is offline or the transcript is unreadable it fires the bare event (or nothing) and never errors. Path assumes the plugin is installed/symlinked at ~/.local/share/noctalia/plugins/claude-companion. Verified against noctalia 5.0.0 (`noctalia msg --help`). SessionStart registers the session at idle; the lifecycle drives turn_start -> tool_start -> turn_end; SessionEnd removes it. For the MCP shim (senses/hands), wire it separately via mcpServers/--mcp-config once shim/noctalia-mcp.py is in use.", + "_comment": "Merge into ~/.claude/settings.json. The attention reflex: Claude Code lifecycle hooks invoke hooks/pulse.py , which reads the hook JSON on stdin, computes live model + token-burn telemetry from the session transcript, and dispatches into the headless aggregator service via `noctalia msg plugin lowcache/claude-companion:pulse-svc all [payload]` (target `all` = every monitor's instance; `focused`/bare connector error when the widget is on multiple bars). payload is a space-free CSV `model,in,out,cacheCreate,cacheRead,session` whose trailing `session` (short id) tags every event, so the service tracks each concurrent session separately and publishes the most urgent state + token-burn telemetry for the widget to render as a tooltip. SessionEnd fires `session_end`, retiring that session in the service and dropping its token cache. The dispatcher is fail-open: if noctalia is offline or the transcript is unreadable it fires the bare event (or nothing) and never errors. Path assumes the plugin is installed/symlinked at ~/.local/share/noctalia/plugins/claude-companion. Verified against noctalia 5.0.0 (`noctalia msg --help`). SessionStart registers the session at idle; the lifecycle drives turn_start -> tool_start -> turn_end; SessionEnd removes it. For the MCP shim (senses/hands), wire it separately via mcpServers/--mcp-config once shim/noctalia-mcp.py is in use.", "hooks": { "SessionStart": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "python3 $HOME/.local/share/noctalia/plugins/claude-companion/hooks/pulse.py idle" } ] } diff --git a/claude-companion/orb.luau b/claude-companion/orb.luau index d62ac66..133fd9e 100644 --- a/claude-companion/orb.luau +++ b/claude-companion/orb.luau @@ -34,21 +34,14 @@ -- ~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. --- User-facing display strings live in translations/.json; the per-state --- status word is keyed by state name ("state.orb.") and resolved at render --- time. cur.state is always a known state (the watch below maps anything else to --- "idle"), so no fallback guard is needed here. -local function tr(key, args) return noctalia.tr(key, args) end -local function orb_word(state) return tr("state.orb." .. state) end - local VISUAL = { - idle = { glyph = "robot", color = "secondary", period = 11.0, omin = 0.45, omax = 0.80 }, - turn_start = { glyph = "brain", color = "primary", period = 7.5, omin = 0.55, omax = 1.00 }, - text = { glyph = "message-dots", color = "primary", period = 7.0, omin = 0.58, omax = 1.00 }, - tool_start = { glyph = "tool", color = "secondary", period = 7.5, omin = 0.55, omax = 1.00 }, - needs_attention = { glyph = "bell-ringing", color = "error", period = 4.5, omin = 0.55, omax = 1.00 }, - turn_end = { glyph = "bell", color = "primary", period = 8.0, omin = 0.55, omax = 0.95 }, - error = { glyph = "alert-triangle", color = "error", period = 5.0, omin = 0.55, omax = 1.00 }, + 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 @@ -61,6 +54,19 @@ 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 @@ -79,20 +85,20 @@ 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() +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 orb_word(cur.state) .. " · " .. kfmt(cur.tin) .. "/" .. kfmt(cur.tout) + return tr(v.word) .. " · " .. kfmt(cur.tin) .. "/" .. kfmt(cur.tout) end - return orb_word(cur.state) + 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 -- the magnification swing + 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 @@ -105,7 +111,7 @@ local function render() width = GLYPH_BOX, height = GLYPH_BOX, }), ui.label({ - text = subtitle(), fontSize = 12, color = "on_surface_variant", + text = subtitle(v), fontSize = 12, color = "on_surface_variant", maxWidth = 180, maxLines = 1, textAlign = "center", }), })) @@ -118,7 +124,9 @@ end -- unbounded. function onFrameTick(dt) local v = VISUAL[cur.state] or VISUAL.idle - phase = (phase + (tonumber(dt) or 0) / 1000) % v.period + phase = (phase + (tonumber(dt) or 0) / 1000 * breath_speed) % v.period + frames = frames + 1 + if frames % 30 == 0 then read_settings() end render() end @@ -135,5 +143,6 @@ noctalia.state.watch("claude.pulse", function(snap) end) -- Breathe from the first frame, even before any session reports in (idle presence). +read_settings() desktopWidget.setNeedsFrameTick(true) render() diff --git a/claude-companion/plugin.toml b/claude-companion/plugin.toml index 1206842..cde242b 100644 --- a/claude-companion/plugin.toml +++ b/claude-companion/plugin.toml @@ -4,24 +4,80 @@ id = "lowcache/claude-companion" name = "Claude Companion" -version = "1.0.1" -# Oldest plugin API level this plugin needs (the [[panel]] kind, ui controls, -# plugin IPC dispatch, the barWidget/onIpc surface). Bump when adopting a newer API. +version = "1.3.0" +# Plugin API level this manifest targets — mandatory as of the Noctalia 5 beta +# manifest parser (replaces the older min_noctalia gate). 3 is the oldest-supported +# level and covers every feature this plugin uses ([[panel]], ui controls, plugin IPC +# dispatch, barWidget/onIpc). The [[service]] entry additionally needs a build that +# ships the Service entry kind (Noctalia 5 beta, upstream rev da014f72 or newer). plugin_api = 3 author = "lowcache" license = "MIT" icon = "robot" description = "Claude Code companion: /claude launch + a telemetry-driven attention pulse." -# The compositor CLI is one-of: the shim detects and speaks whichever of niri / -# Hyprland / Sway is running Noctalia (so exactly one of niri/hyprctl/swaymsg is -# present). See README "Requirements". -dependencies = ["claude", "python3", "niri", "hyprctl", "swaymsg", "playerctl", "notify-send", "nmcli", "ps", "tr", "timeout"] -tags = ["ai", "productivity", "bar", "desktop", "panel", "launcher", "niri", "hyprland", "sway"] +# The compositor CLI (niri / hyprctl / swaymsg) is not listed: the shim detects and +# speaks whichever of the three supported compositors is running Noctalia, so exactly +# one is inherently present. See README "Requirements". +dependencies = ["claude", "python3", "playerctl", "notify-send", "nmcli", "ps", "tr", "timeout"] +tags = ["ai", "productivity", "bar", "desktop", "panel", "launcher"] -# The attention pulse — the visual centerpiece. Reads agent state from -# noctalia.state ("claude.state") and from hook signals (onIpc), reflects it in the -# bar as a glyph + a sine-breath brightness glow (folded in from the barpulse -# A/B prototype; barpulse.luau stays in-repo as the experiment record, unregistered). +# ── User settings ───────────────────────────────────────────────────────────── +# Breath-speed multiplier for the pulse dot + orb animation (plugin-level, so both +# surfaces read the same value via noctalia.getConfig). 1.0 is the tuned default; +# higher breathes faster, lower slower. Rendered as a slider; `step` MUST be set +# explicitly on every double — the manifest parser defaults step to 1.0 +# (plugin_manifest.h: `double step = 1.0`), which snaps a fractional range to a +# handful of preset stops instead of sliding. +[[setting]] +key = "breath_speed" +type = "double" +label_key = "settings.breath_speed.label" +description_key = "settings.breath_speed.description" +default = 1.0 +min = 0.25 +max = 3.0 +step = 0.05 + +# Minimum brightness the bar dot dims to at the trough of its breath (0 = fully dark, +# 0.9 = barely dims). 0.45 is the tuned default. (Plugin-level so it sits with the +# other look controls; pulse.luau reads it via getConfig.) +[[setting]] +key = "pulse_glow_floor" +type = "double" +label_key = "settings.pulse_glow_floor.label" +description_key = "settings.pulse_glow_floor.description" +default = 0.45 +min = 0.0 +max = 0.9 +step = 0.05 + +# How far the orb glyph magnifies as it breathes (0 = static size, 1.0 = the tuned +# default, higher = a bigger swing). orb.luau reads it via getConfig. +[[setting]] +key = "orb_swell" +type = "double" +label_key = "settings.orb_swell.label" +description_key = "settings.orb_swell.description" +default = 1.0 +min = 0.0 +max = 3.0 +step = 0.05 + +# The pulse aggregator (headless) — the single source of truth for Claude session +# state. Receives hook events via onIpc (noctalia msg plugin …:pulse-svc all ) +# and the launcher quick-ask via state.watch("claude.state"), rolls all sessions up, +# and publishes the snapshot to noctalia.state ("claude.pulse"). Runs at shell launch +# with no surface, so capture never depends on the bar widget being placed — this +# retires the old "pulse must sit on a bar" invariant (see PROTOCOL.md). Needs the +# [[service]] entry kind (Noctalia 5 beta / post-5.0.0). +[[service]] +id = "pulse-svc" +entry = "pulse-svc.luau" + +# The attention pulse (bar) — the visual centerpiece. A pure subscriber: watches +# noctalia.state ("claude.pulse") published by pulse-svc and reflects it in the bar as +# a glyph + a sine-breath brightness glow (folded in from the barpulse A/B prototype; +# barpulse.luau stays in-repo as the experiment record, unregistered). [[widget]] id = "pulse" entry = "pulse.luau" @@ -43,6 +99,17 @@ entry = "answer.luau" width = 460 height = 420 +# The sessions panel — the actionable form of the bar tooltip. One row per live +# session (state, model, token burn) plus a retire control for a session whose +# SessionEnd hook never fired. Opens on a RIGHT-click of the bar pulse (left-click +# stays the answer panel), or via +# `noctalia msg panel-toggle lowcache/claude-companion:sessions`. +[[panel]] +id = "sessions" +entry = "sessions.luau" +width = 440 +height = 360 + # /claude — launch a real Claude Code session in the terminal, or a one-shot ask. # The single backend chokepoint (invoke/parse) is inlined here: v5 has no plugin # module system, and this is the only entry that talks to a model. diff --git a/claude-companion/pulse-svc.luau b/claude-companion/pulse-svc.luau new file mode 100644 index 0000000..b8cf277 --- /dev/null +++ b/claude-companion/pulse-svc.luau @@ -0,0 +1,170 @@ +-- The pulse aggregator (headless [[service]]) — the single source of truth for +-- Claude session state across ALL active sessions. This is the reflex half of the +-- attention pulse, split out of the bar widget (pulse.luau) so capture no longer +-- depends on the bar dot being placed: a [[service]] runtime starts at shell launch +-- and stays alive regardless of surfaces, retiring the old "pulse must sit on a bar" +-- invariant (D10 / PROTOCOL.md "Deployment invariant"). +-- +-- Two feeds converge on the session table, both event-driven (no polling): +-- • Claude Code hooks → onIpc (the reflex): +-- noctalia msg plugin lowcache/claude-companion:pulse-svc all [payload] +-- payload = "model,in,out,cacheCreate,cacheRead,session" (hooks/pulse.py). +-- Each real session is tracked by its id; session_end removes it. +-- • claude.luau writes "claude.state" (the launcher quick-ask) → watched here as +-- one ephemeral pseudo-session ("ask"), removed when the ask completes. +-- +-- On every change it republishes a rollup to noctalia.state ("claude.pulse"); the +-- bar dot (pulse.luau) and the desktop orb (orb.luau) are pure subscribers of that +-- key — one source of truth, two surfaces. The service defines NO update(): it is +-- purely event-driven, so the host's per-service timer tick is a cheap no-op. + +-- ── priority (aggregation only) ────────────────────────────────────────────── +-- With several sessions in different states, the rollup reports the most urgent: +-- a session that needs you outranks one merely working, which outranks one idle. +local STATE_PRIO = { + needs_attention = 6, error = 5, tool_start = 4, + turn_start = 3, text = 3, turn_end = 2, idle = 1, +} + +-- sid -> { sid, state, model, tin, tout, cr, seq }. `seq` is a monotonic counter +-- (no os.time dependency in the sandbox) used to order sessions by recency. +local sessions = {} +local seq = 0 +local last_ask = nil -- last claude.state value folded into the "ask" session + +-- ── payload plumbing ───────────────────────────────────────────────────────── +local function split(s, sep) + local out = {} + for part in (s .. sep):gmatch("(.-)" .. sep) do out[#out + 1] = part end + return out +end + +-- "model,in,out,cacheCreate,cacheRead,session" -> a session delta, or nil. "in" +-- (fresh prompt tokens) is tiny next to cache reads, so the displayed input is +-- fresh + cache-create (full-rate work); cache reads are tracked separately. +local function parse_payload(tel) + if not tel or tel == "" then return nil end + local f = split(tel, ",") + local sid = f[6] + if not sid or sid == "" then return nil end + return { + sid = sid, + model = (f[1] and f[1] ~= "") and f[1] or "?", + tin = (tonumber(f[2]) or 0) + (tonumber(f[4]) or 0), + tout = tonumber(f[3]) or 0, + cr = tonumber(f[5]) or 0, + } +end + +local function has_burn(s) + return s.model and s.model ~= "?" and ((s.tin or 0) + (s.tout or 0)) > 0 +end + +local function ordered() + local arr = {} + for _, s in pairs(sessions) do arr[#arr + 1] = s end + table.sort(arr, function(a, b) return (a.seq or 0) > (b.seq or 0) end) + return arr +end + +local function touch(sid, state, p) + seq = seq + 1 + local s = sessions[sid] or { sid = sid } + s.state = state + s.seq = seq + if p then + s.model, s.tin, s.tout, s.cr = p.model, p.tin, p.tout, p.cr + end + sessions[sid] = s +end + +-- ── publish ────────────────────────────────────────────────────────────────── +-- Roll every live session up to the most-urgent state + burn totals and mirror it +-- to shared state. Both surfaces subscribe to "claude.pulse" and never re-derive it. +-- Schema (v2): top-level fields (state/count/model/tin/tout/cr) are the single-glance +-- rollup the orb reads — single-session values when count==1, the Σ when >1. The +-- `sessions` array (most-recent first) carries per-session detail for the bar's +-- multi-session tooltip; the orb ignores it, so the top-level shape stays backward +-- compatible. Published only on events (never a timer), so subscribers aren't spammed. +local function publish() + local arr = ordered() + local count = #arr + + local best, bestp = "idle", 0 + for _, s in ipairs(arr) do + local p = STATE_PRIO[s.state] or 0 + if p > bestp then bestp = p; best = s.state end + end + + local snap = { state = best, count = count, model = "?", tin = 0, tout = 0, cr = 0, sessions = {} } + for i, s in ipairs(arr) do + snap.sessions[i] = { + sid = s.sid, state = s.state, model = s.model or "?", + tin = s.tin or 0, tout = s.tout or 0, cr = s.cr or 0, + } + end + if count == 1 and has_burn(arr[1]) then + local s = arr[1] + snap.model, snap.tin, snap.tout, snap.cr = s.model, s.tin, s.tout, s.cr + elseif count > 1 then + local Tin, Tout = 0, 0 + for _, s in ipairs(arr) do + if has_burn(s) then Tin = Tin + s.tin; Tout = Tout + s.tout end + end + snap.tin, snap.tout = Tin, Tout + end + noctalia.state.set("claude.pulse", snap) +end + +-- ── quick-ask feed (launcher) ──────────────────────────────────────────────── +-- claude.luau publishes the /claude ? stream state to "claude.state" (no session id, +-- no telemetry). Shown while streaming, dropped when it finishes — the answer is +-- delivered via notify, so a lingering "done" would only inflate the session count. +-- Event-driven via state.watch (cross-runtime in Noctalia 5 beta), so the service +-- holds no timer for it. Only a *change* touches the session table + republishes. +local function fold_ask(v) + local s = (type(v) == "string" and v ~= "") and v or nil + if s == last_ask then return end + last_ask = s + if s == nil or s == "turn_end" or s == "error" then + sessions["ask"] = nil + else + touch("ask", s, nil) + end + publish() +end + +-- ── hook reflex ────────────────────────────────────────────────────────────── +-- Per-session state + token telemetry, full lifecycle. The dispatcher always tags +-- the event with a session id; session_end (the Claude Code SessionEnd hook) retires +-- the session so stale entries never accumulate. +-- +-- A payload-less event carries no session id (real hook events always do) — only a +-- manual `noctalia msg … :pulse-svc all ` poke from the CLI does. Those land +-- in a single "default" test slot. To keep such a poke from leaving a sticky phantom +-- session, any RESTING state (idle / turn_end / error) retires "default" too — so +-- `… all idle` cleanly clears the orb after a manual test, without a plugin reload. +local MANUAL_REST = { idle = true, turn_end = true, error = true } +function onIpc(event, payload) + if type(event) ~= "string" then return end + local p = parse_payload(payload) + local sid = (p and p.sid) or "default" + if event == "session_end" or (sid == "default" and MANUAL_REST[event]) then + sessions[sid] = nil + else + touch(sid, event, p) + end + publish() +end + +-- ── init ───────────────────────────────────────────────────────────────────── +-- Watch the quick-ask channel, then fold any value already present (a stream in +-- flight when the service (re)loads) and publish an initial idle rollup so a +-- subscriber that reads "claude.pulse" before the first event sees a valid state. +noctalia.state.watch("claude.state", fold_ask) +local init = noctalia.state.get and noctalia.state.get("claude.state") +if type(init) == "string" and init ~= "" then + last_ask = init + if not (init == "turn_end" or init == "error") then touch("ask", init, nil) end +end +publish() diff --git a/claude-companion/pulse.luau b/claude-companion/pulse.luau index 95785dc..fc856a8 100644 --- a/claude-companion/pulse.luau +++ b/claude-companion/pulse.luau @@ -1,26 +1,19 @@ --- The attention pulse (bar widget) — a glanceable live readout of Claude across --- ALL active sessions. Two feeds converge on the session table: --- • Claude Code hooks fire the plugin-dispatch IPC → onIpc here (the reflex): --- noctalia msg plugin lowcache/claude-companion:pulse all [payload] --- payload = "model,in,out,cacheCreate,cacheRead,session" (hooks/pulse.py). --- Each real session is tracked by its id; SessionEnd removes it. --- • claude.luau writes "claude.state" (the launcher quick-ask) → POLLED here (bar --- widgets don't fire state.watch in v5 — D8 / README) as one ephemeral pseudo- --- session ("ask"), removed when the ask completes. +-- The attention pulse (bar widget) — a glanceable live readout of Claude across all +-- active sessions. This is the VIEW half of the pulse: a pure subscriber. The +-- aggregator (pulse-svc.luau, a headless [[service]]) owns all session bookkeeping and +-- publishes a rollup to noctalia.state ("claude.pulse"); this widget watches that key +-- and reflects it in the bar — a glyph (Tabler icon name; an unknown name renders the +-- skull fallback) whose accent color breathes a raised-cosine BRIGHTNESS glow (the bar +-- ignores 8-digit #RRGGBBAA alpha, but a 6-digit #RRGGBB scaled toward black reads as a +-- glow). Discrete state (glyph/tooltip) renders on each snapshot; the 60 ms timer only +-- advances the breath. A state change snaps the breath to its peak, so transitions read +-- as a bright flash before settling into the rhythm. -- --- Bar plugin widgets have no per-frame tick (that's desktop-only), so the breath --- runs on noctalia.setUpdateInterval(ms) + the global update(): a raised-cosine --- BRIGHTNESS breath (the winner of the barpulse A/B prototype — the bar ignores --- 8-digit #RRGGBBAA alpha, but a 6-digit #RRGGBB scaled toward black reads as a --- glow). Discrete state (glyph/tooltip/session rollup) renders on events; the --- timer only advances the breath. A state change snaps the breath to its peak, --- so transitions read as a bright flash before settling into the rhythm. --- --- The bar API is the `barWidget.*` table (NOT `widget`). Glyph names are Tabler --- icon names; an unknown name renders the skull fallback. --- The companion presence orb (orb.luau, a [[desktop_widget]]) is a pure view: --- this bar dot mirrors its session rollup to noctalia.state ("claude.pulse", at the --- end of render below) and the orb subscribes — same state map, two surfaces. +-- The companion presence orb (orb.luau, a [[desktop_widget]]) subscribes to the same +-- "claude.pulse" key: one source of truth (the service), the bar dot and the orb are +-- two views of it. Bar widgets DO receive noctalia.state.watch callbacks as of the +-- Noctalia 5 beta (the old "bars must poll" workaround is gone). The bar API is the +-- `barWidget.*` table (NOT `widget`). -- ── live palette ───────────────────────────────────────────────────────────── -- Accent roles follow the global scheme so the dot matches the other bar @@ -43,6 +36,8 @@ local function xdg(env, fallback) return noctalia.expandPath(fallback) end +local function tr(key, args) return noctalia.tr(key, args) end + -- Minimal [theme] reader: walk lines, track the section, keep quoted k/v pairs. -- (Indented subsections like [theme.templates] end the block; their keys are -- arrays/bools and would not match the quoted-string pattern anyway.) @@ -87,58 +82,54 @@ local function resolve_accents() ACCENTS.error = hex(m.mError) or ACCENTS.error end --- User-facing display strings live in translations/.json; resolve at call --- time. Per-state tips ("state.tip.") and compact words ("state.word. --- ") are keyed by state name. KNOWN_STATE guards a bogus state from a --- manual CLI poke so it falls back to raw text / idle exactly as the old tables did. -local function tr(key, args) return noctalia.tr(key, args) end -local KNOWN_STATE = { - idle = true, turn_start = true, text = true, tool_start = true, - needs_attention = true, turn_end = true, error = true, -} -local function state_tip(s) return tr("state.tip." .. (KNOWN_STATE[s] and s or "idle")) end -local function state_word(s) return KNOWN_STATE[s] and tr("state.word." .. s) or s end - -- ── state map ──────────────────────────────────────────────────────────────── -- `color` names an ACCENTS role; `period` is the breath cycle in seconds — --- urgency reads as tempo (needs-you breathes fast, idle slow). Tooltip text is --- resolved from translations by state name (state_tip/state_word above). +-- urgency reads as tempo (needs-you breathes fast, idle slow). local VISUAL = { - idle = { glyph = "robot", color = "secondary", period = 8.0 }, - turn_start = { glyph = "brain", color = "primary", period = 5.0 }, - text = { glyph = "message-dots", color = "primary", period = 4.5 }, - tool_start = { glyph = "tool", color = "secondary", period = 5.0 }, - needs_attention = { glyph = "bell-ringing", color = "error", period = 3.0 }, - turn_end = { glyph = "bell", color = "primary", period = 5.5 }, - error = { glyph = "alert-triangle", color = "error", period = 3.5 }, + idle = { glyph = "robot", color = "secondary", tip = "state.tip.idle", period = 8.0 }, + turn_start = { glyph = "brain", color = "primary", tip = "state.tip.turn_start", period = 5.0 }, + text = { glyph = "message-dots", color = "primary", tip = "state.tip.text", period = 4.5 }, + tool_start = { glyph = "tool", color = "secondary", tip = "state.tip.tool_start", period = 5.0 }, + needs_attention = { glyph = "bell-ringing", color = "error", tip = "state.tip.needs_attention", period = 3.0 }, + turn_end = { glyph = "bell", color = "primary", tip = "state.tip.turn_end", period = 5.5 }, + error = { glyph = "alert-triangle", color = "error", tip = "state.tip.error", period = 3.5 }, } --- With several sessions in different states, the bar shows the most urgent one: --- a session that needs you outranks one merely working, which outranks one idle. -local STATE_PRIO = { - needs_attention = 6, error = 5, tool_start = 4, - turn_start = 3, text = 3, turn_end = 2, idle = 1, +-- Compact per-session words for the multi-session tooltip (Stage 2, once the +-- rollup carries a per-session list). +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", } --- sid -> { sid, state, model, tin, tout, cr, seq }. `seq` is a monotonic counter --- (no os.time dependency in the widget sandbox) used to order by recency. -local sessions = {} -local seq = 0 - -- ── breath ─────────────────────────────────────────────────────────────────── local INTERVAL_MS = 60 -- ~16 fps re-render (bars can't do 60 fps) local BMIN, BMAX = 0.45, 1.00 -- brightness floor/ceiling the glyph breathes between local PALETTE_EVERY = 128 -- re-resolve accents every ~7.7 s so theme changes follow +local SETTINGS_EVERY = 16 -- re-read user settings ~1 s so slider drags apply promptly -local cur = "idle" -- most-urgent state across sessions (drives glyph + tempo) +-- Latest rollup from the aggregator (pulse-svc), received via state.watch. `cur` +-- tracks the state currently driving the glyph + tempo so a change can snap the breath. +local snap = { state = "idle", count = 0, model = "?", tin = 0, tout = 0, cr = 0 } +local cur = "idle" local phase = VISUAL.idle.period / 2 -- breath clock, seconds; born at peak brightness local ticks = 0 -local last_ask = nil -- last claude.state seen by the quick-ask poll (below) +local breath_speed = 1.0 -- user setting (breath_speed): phase-rate multiplier +local glow_floor = BMIN -- user setting (pulse_glow_floor): brightness floor at the breath trough -local function level_for(period) -- raised-cosine brightness in [BMIN, BMAX] +-- Re-read user settings (cheap; called at load + periodically from update()). +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 g = noctalia.getConfig and noctalia.getConfig("pulse_glow_floor") + if type(g) == "number" and g >= 0 and g < BMAX then glow_floor = g end +end + +local function level_for(period) -- raised-cosine brightness in [glow_floor, BMAX] local t = phase % period local s = 0.5 - 0.5 * math.cos((t / period) * 2 * math.pi) - return BMIN + (BMAX - BMIN) * s + return glow_floor + (BMAX - glow_floor) * s end -- Scale an "RRGGBB" accent toward black by factor b, returning "#RRGGBB". @@ -156,13 +147,7 @@ local function paint() barWidget.setGlyphColor(dimmed(ACCENTS[v.color] or ACCENTS.secondary, level_for(v.period))) end --- ── session plumbing ───────────────────────────────────────────────────────── -local function split(s, sep) - local out = {} - for part in (s .. sep):gmatch("(.-)" .. sep) do out[#out + 1] = part end - return out -end - +-- ── tooltip helpers ────────────────────────────────────────────────────────── local function kfmt(s) local n = tonumber(s) or 0 if n >= 1e6 then return string.format("%.1fM", n / 1e6) end @@ -170,23 +155,6 @@ local function kfmt(s) return tostring(math.floor(n)) end --- "model,in,out,cacheCreate,cacheRead,session" -> a session delta, or nil. "in" --- (fresh prompt tokens) is tiny next to cache reads, so the displayed input is --- fresh + cache-create (full-rate work); cache reads are tracked separately. -local function parse_payload(tel) - if not tel or tel == "" then return nil end - local f = split(tel, ",") - local sid = f[6] - if not sid or sid == "" then return nil end - return { - sid = sid, - model = (f[1] and f[1] ~= "") and f[1] or "?", - tin = (tonumber(f[2]) or 0) + (tonumber(f[4]) or 0), - tout = tonumber(f[3]) or 0, - cr = tonumber(f[5]) or 0, - } -end - local function has_burn(s) return s.model and s.model ~= "?" and ((s.tin or 0) + (s.tout or 0)) > 0 end @@ -197,128 +165,82 @@ local function burn_line(s) return l end -local function ordered() - local arr = {} - for _, s in pairs(sessions) do arr[#arr + 1] = s end - table.sort(arr, function(a, b) return (a.seq or 0) > (b.seq or 0) end) - return arr -end - +-- ── render (pure view of the rollup) ───────────────────────────────────────── +-- Glyph + accent for the most-urgent state, a tooltip, and a breath snapped to peak +-- on a state change. The service is the single aggregator; this only views its rollup. +-- The multi-session tooltip currently shows count + Σ burn; per-session lines return +-- in Stage 2 when the rollup carries a `sessions` list (see STATE_WORD). local function render() - local arr = ordered() - local count = #arr - - local best, bestp = "idle", 0 - for _, s in ipairs(arr) do - local p = STATE_PRIO[s.state] or 0 - if p > bestp then bestp = p; best = s.state end - end - if best ~= cur then - cur = best - -- snap the breath to its peak so a state change reads as a bright flash - phase = (VISUAL[cur] or VISUAL.idle).period / 2 + if snap.state ~= cur then + cur = snap.state + phase = (VISUAL[cur] or VISUAL.idle).period / 2 -- snap the breath to its peak (bright flash) end local v = VISUAL[cur] or VISUAL.idle barWidget.setGlyph(v.glyph) paint() local tip - if count == 0 then - tip = state_tip("idle") - elseif count == 1 then - local s = arr[1] - local base = state_tip(s.state) - tip = has_burn(s) and (base .. "\n" .. burn_line(s)) or base + if snap.count == 0 then + tip = tr(VISUAL.idle.tip) + elseif snap.count == 1 then + tip = has_burn(snap) and (tr(v.tip) .. "\n" .. burn_line(snap)) or tr(v.tip) else - local lines = { tr("pulse.sessions_header", { count = count }) } + -- A thin rule separates each session so concurrent sessions read as distinct + -- blocks rather than one wall of text (fixed width; tooltip font is proportional + -- so it reads as a divider, not a measured column). + local DIV = "──────────────" + local lines = { tr("pulse.sessions_header", { count = snap.count }) } local Tin, Tout = 0, 0 - for _, s in ipairs(arr) do - local line = s.sid .. " · " .. state_word(s.state) + for i, s in ipairs(snap.sessions) do + if i > 1 then lines[#lines + 1] = DIV end + local sw = STATE_WORD[s.state] and tr(STATE_WORD[s.state]) or s.state + local line = s.sid .. " · " .. sw if has_burn(s) then line = line .. " · " .. s.model .. " " .. kfmt(s.tin) .. "/" .. kfmt(s.tout) Tin = Tin + s.tin; Tout = Tout + s.tout end lines[#lines + 1] = line end - if (Tin + Tout) > 0 then + -- fallback to the rollup's Σ figures if no per-session list was published + if #snap.sessions == 0 and (snap.tin + snap.tout) > 0 then + lines[#lines + 1] = DIV + lines[#lines + 1] = tr("pulse.total", { tin = kfmt(snap.tin), tout = kfmt(snap.tout) }) + elseif (Tin + Tout) > 0 then + lines[#lines + 1] = DIV lines[#lines + 1] = tr("pulse.total", { tin = kfmt(Tin), tout = kfmt(Tout) }) end tip = table.concat(lines, "\n") end barWidget.setTooltip(tip) +end - -- Mirror the rollup to shared state so the presence orb (orb.luau, a separate - -- desktop widget) renders the same status without re-deriving it. The bar dot - -- stays the single place that aggregates sessions; the orb is a pure subscriber. - -- Published only here (events), never from the breath timer, so orb watchers - -- aren't spammed 16×/s. Snapshot carries only what the orb needs: the most- - -- urgent state, the session count, and burn totals (single-session figures - -- when count==1, the sum when >1). - local snap = { state = best, count = count, model = "?", tin = 0, tout = 0, cr = 0 } - if count == 1 and has_burn(arr[1]) then - local s = arr[1] - snap.model, snap.tin, snap.tout, snap.cr = s.model, s.tin, s.tout, s.cr - elseif count > 1 then - local Tin, Tout = 0, 0 - for _, s in ipairs(arr) do - if has_burn(s) then Tin = Tin + s.tin; Tout = Tout + s.tout end +-- Normalize + adopt a "claude.pulse" snapshot, then render. Defensive against a +-- malformed or partial payload (state store round-trips values as JSON). The +-- `sessions` array (v2) feeds the multi-session tooltip; older publishers omit it. +local function apply(s) + if type(s) ~= "table" then return end + local sess = {} + if type(s.sessions) == "table" then + for i, e in ipairs(s.sessions) do + if type(e) == "table" then + sess[i] = { + sid = tostring(e.sid or "?"), + state = type(e.state) == "string" and e.state or "idle", + model = (type(e.model) == "string" and e.model ~= "") and e.model or "?", + tin = tonumber(e.tin) or 0, tout = tonumber(e.tout) or 0, cr = tonumber(e.cr) or 0, + } + end end - snap.tin, snap.tout = Tin, Tout - end - noctalia.state.set("claude.pulse", snap) -end - -local function touch(sid, state, p) - seq = seq + 1 - local s = sessions[sid] or { sid = sid } - s.state = state - s.seq = seq - if p then - s.model, s.tin, s.tout, s.cr = p.model, p.tin, p.tout, p.cr - end - sessions[sid] = s -end - --- launcher quick-ask path (no session id, no telemetry). Ephemeral: shown while --- streaming, dropped when it finishes — the answer is delivered via notify, so a --- lingering "done" would only inflate the session count. --- --- POLLED, not watched: bar widgets don't fire noctalia.state.watch callbacks in --- v5 (D8 / README "Rough edges"), so update() reads claude.state each tick the --- same way it re-reads the palette. Only a *change* touches the session table and --- re-renders, so a steady state costs a single state.get and nothing more. -local function poll_ask() - local s = noctalia.state.get and noctalia.state.get("claude.state") - if type(s) ~= "string" then s = nil end - if s == last_ask then return end - last_ask = s - if s == nil or s == "turn_end" or s == "error" then - sessions["ask"] = nil - else - touch("ask", s, nil) - end - render() -end - --- hook reflex path: per-session state + token telemetry, full lifecycle. The --- dispatcher always tags the event with a session id; `session_end` (the Claude --- Code SessionEnd hook) retires the session so stale entries never accumulate. --- --- A payload-less event carries no session id (real hook events always do) — only a --- manual `noctalia msg … :pulse all ` poke from the CLI does. Those land in a --- single "default" test slot. To keep such a poke from leaving a sticky phantom --- session, any RESTING state (idle / turn_end / error) retires "default" too — so --- `… all idle` cleanly clears the orb after a manual test, without a plugin reload. -local MANUAL_REST = { idle = true, turn_end = true, error = true } -function onIpc(event, payload) - if type(event) ~= "string" then return end - local p = parse_payload(payload) - local sid = (p and p.sid) or "default" - if event == "session_end" or (sid == "default" and MANUAL_REST[event]) then - sessions[sid] = nil - else - touch(sid, event, p) end + snap = { + state = (type(s.state) == "string" and VISUAL[s.state]) and s.state or "idle", + count = tonumber(s.count) or 0, + model = (type(s.model) == "string" and s.model ~= "") and s.model or "?", + tin = tonumber(s.tin) or 0, + tout = tonumber(s.tout) or 0, + cr = tonumber(s.cr) or 0, + sessions = sess, + } render() end @@ -334,19 +256,36 @@ function onClick() end end +-- Right-click opens the sessions panel: the tooltip already lists concurrent +-- sessions, but you cannot act on a tooltip — it disappears on the way to it. Same +-- panel-open (not -toggle) reasoning as onClick above. onRightClick needs no +-- plugin_api bump: the dispatch in plugin_widget.cpp is ungated (the level-14 +-- gate covers the DECLARATIVE `actions` manifest table, not these globals). +function onRightClick() + if not noctalia.runAsync("noctalia msg panel-open 'lowcache/claude-companion:sessions'") then + noctalia.notifyError(tr("pulse.title"), tr("pulse.panel_launch_failed")) + end +end + -- Breath timer. Re-arm the interval each tick (the pattern proven live in the -- barpulse prototype), advance the clock, repaint the glyph brightness only — --- glyph/tooltip/rollup are event-driven in render(). +-- glyph/tooltip are event-driven in render() (fired from the state.watch below). function update() noctalia.setUpdateInterval(INTERVAL_MS) - phase = phase + INTERVAL_MS / 1000 + phase = phase + INTERVAL_MS / 1000 * breath_speed if phase > 1e6 then phase = 0 end ticks = ticks + 1 if ticks % PALETTE_EVERY == 0 then resolve_accents() end - poll_ask() -- surface quick-ask state changes (bar widgets can't watch) + if ticks % SETTINGS_EVERY == 0 then read_settings() end paint() end +-- Subscribe to the aggregator's rollup, seed from any snapshot already published +-- before we subscribed (the service publishes at launch), then paint an initial +-- frame even if nothing has reported yet (defaults to idle). +noctalia.state.watch("claude.pulse", apply) resolve_accents() +read_settings() noctalia.setUpdateInterval(INTERVAL_MS) +apply(noctalia.state.get and noctalia.state.get("claude.pulse")) render() diff --git a/claude-companion/sessions.luau b/claude-companion/sessions.luau new file mode 100644 index 0000000..5db2985 --- /dev/null +++ b/claude-companion/sessions.luau @@ -0,0 +1,171 @@ +-- 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 ",,,,," 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 diff --git a/claude-companion/tests/manifest_spec.py b/claude-companion/tests/manifest_spec.py new file mode 100644 index 0000000..6d56846 --- /dev/null +++ b/claude-companion/tests/manifest_spec.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Manifest invariants for plugin.toml — the settings contract. + +Covers the one class of defect neither `noctalia plugins lint` nor the luau specs +can see: `lint` only cross-checks declared settings against getConfig() calls, and +the widget code never observes a slider's step, so a wrong step is invisible to +both. Run: python3 tests/manifest_spec.py + +The load-bearing invariant is STEP. Noctalia's manifest parser defaults an +omitted step to 1.0 (plugin_manifest.h: `double step = 1.0`), so a fractional +range silently degenerates to min + n*1.0 clamped to max — a handful of preset +stops instead of a slider. Shipped exactly that way once: pulse_glow_floor +(0.0-0.9) could only reach 0.0 and 0.9. Every double MUST declare step. +""" +import os +import unittest + +try: + import tomllib +except ModuleNotFoundError: # py<3.11 + import tomli as tomllib # type: ignore + +ROOT = os.environ.get("PLUGIN_ROOT") or os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +NUMERIC = ("double", "number", "float") + + +def _settings(manifest): + """Every declared setting, plugin-level and entry-level, as (origin, dict).""" + out = [("[[setting]]", s) for s in manifest.get("setting", [])] + for kind in ("widget", "desktop_widget", "panel", "service", "launcher"): + for entry in manifest.get(kind, []): + for s in entry.get("setting", []): + out.append((f"[[{kind}.setting]] {entry.get('id', '?')}", s)) + return out + + +class Manifest(unittest.TestCase): + @classmethod + def setUpClass(cls): + with open(os.path.join(ROOT, "plugin.toml"), "rb") as fh: + cls.manifest = tomllib.load(fh) + cls.settings = _settings(cls.manifest) + + def test_has_settings(self): + self.assertTrue(self.settings, "expected at least one declared setting") + + def test_every_numeric_declares_explicit_step(self): + """The regression guard. An omitted step means 1.0, not 'continuous'.""" + for origin, s in self.settings: + if s.get("type") in NUMERIC: + with self.subTest(setting=s.get("key"), origin=origin): + self.assertIn( + "step", s, + f"{s.get('key')} ({origin}) is type={s.get('type')} with no explicit " + "step; the parser would default it to 1.0 and snap the slider", + ) + + def test_step_is_positive(self): + """step <= 0 is a hard parse error in noctalia (rejects the whole manifest).""" + for origin, s in self.settings: + if "step" in s: + with self.subTest(setting=s.get("key"), origin=origin): + self.assertGreater(s["step"], 0, f"{s.get('key')} step must be > 0") + + def test_step_is_finer_than_range(self): + """A step >= the span leaves only the two clamped endpoints reachable.""" + for origin, s in self.settings: + if "step" in s and "min" in s and "max" in s: + with self.subTest(setting=s.get("key"), origin=origin): + span = s["max"] - s["min"] + self.assertLess( + s["step"], span, + f"{s.get('key')} step {s['step']} is not finer than its range {span}", + ) + + def test_default_lands_on_a_step_boundary(self): + """Otherwise the shipped default is a value the slider cannot return to.""" + for origin, s in self.settings: + if {"step", "min", "default"} <= s.keys() and s.get("type") in NUMERIC: + with self.subTest(setting=s.get("key"), origin=origin): + steps = (s["default"] - s["min"]) / s["step"] + self.assertAlmostEqual( + steps, round(steps), places=6, + msg=f"{s.get('key')} default {s['default']} is not an integer number " + f"of {s['step']} steps from min {s['min']}", + ) + + def test_default_within_range(self): + for origin, s in self.settings: + if {"min", "max", "default"} <= s.keys(): + with self.subTest(setting=s.get("key"), origin=origin): + self.assertGreaterEqual(s["default"], s["min"]) + self.assertLessEqual(s["default"], s["max"]) + + def test_label_and_description_use_key_form(self): + """Raw `label`/`description` are REJECTED by the parser; only *_key works.""" + for origin, s in self.settings: + with self.subTest(setting=s.get("key"), origin=origin): + self.assertNotIn("label", s, f"{s.get('key')}: use label_key, not label") + self.assertNotIn("description", s, f"{s.get('key')}: use description_key") + self.assertIn("label_key", s, f"{s.get('key')} is missing label_key") + + +class Translations(unittest.TestCase): + """Every *_key must resolve in translations/en.json, or the UI shows a raw key.""" + + @classmethod + def setUpClass(cls): + import json + with open(os.path.join(ROOT, "plugin.toml"), "rb") as fh: + cls.settings = _settings(tomllib.load(fh)) + with open(os.path.join(ROOT, "translations", "en.json"), encoding="utf-8") as fh: + cls.en = json.load(fh) + + def _resolve(self, dotted): + node = self.en + for part in dotted.split("."): + if not isinstance(node, dict) or part not in node: + return None + node = node[part] + return node + + def test_every_key_resolves(self): + for origin, s in self.settings: + for field in ("label_key", "description_key"): + if field in s: + with self.subTest(setting=s.get("key"), field=field, origin=origin): + self.assertIsInstance( + self._resolve(s[field]), str, + f"{s[field]} does not resolve to a string in translations/en.json", + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/claude-companion/translations/en.json b/claude-companion/translations/en.json index 9fb22c5..7e1111c 100644 --- a/claude-companion/translations/en.json +++ b/claude-companion/translations/en.json @@ -28,6 +28,26 @@ "title": "pulse", "total": "Σ {tin} in / {tout} out" }, + "sessions": { + "empty": "No live Claude sessions. Start one in a terminal, or with /claude.", + "retire": "Retire", + "title": "Claude — {count} sessions", + "title_one": "Claude — 1 session" + }, + "settings": { + "breath_speed": { + "description": "How fast the pulse dot and orb breathe. 1.0 is the default; higher is faster, lower is slower.", + "label": "Breath speed" + }, + "orb_swell": { + "description": "How far the desktop orb magnifies as it breathes. 0 keeps it a static size; 1.0 is the default; higher swells more.", + "label": "Orb swell" + }, + "pulse_glow_floor": { + "description": "How dim the bar dot gets at the low point of its breath. 0 dims to black; higher stays brighter. 0.45 is the default.", + "label": "Bar dot glow floor" + } + }, "state": { "orb": { "error": "error",