diff --git a/README.md b/README.md index 7db8e07..f53b7ea 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,28 @@ left = ["menu", "core.spacer"] right = ["group:system", "core.clock"] ``` +## claude.context — the agent you are not watching + +`claude.context` is a readout of how full the Claude Code context window is. +macarchy-core's status line drops each session's percentage in +`$XDG_RUNTIME_DIR/macarchy-claude/` as it renders; the module reads +them back every five seconds, shows the *fullest* one and sweeps the files of +sessions that have exited. Green under 60 %, orange, then red at 85 %, where a +compaction is close. Running several sessions at once, the pill carries a badge +with how many are alive — the number that matters when three agents are working +in branches you cannot see. + +```toml +[items.claude] +widget = "claude.context" + +[layouts.default] +right = ["claude", "core.clock"] +``` + +With no status line running the pill stays a plain icon, so it costs nothing to +leave in the layout. + ## Writing a module A module lives in its own directory with a `manifest.json`: diff --git a/modules/claude/manifest.json b/modules/claude/manifest.json new file mode 100644 index 0000000..8c9e091 --- /dev/null +++ b/modules/claude/manifest.json @@ -0,0 +1,2 @@ +{ "schemaVersion": 1, "id": "claude", "name": "Claude Code", "kinds": ["touchbar-module"], + "entryPoints": { "touchbarModule": "touchbar.py" }, "touchbarModule": { "apiVersion": 1, "order": 25 } } diff --git a/modules/claude/touchbar.py b/modules/claude/touchbar.py new file mode 100644 index 0000000..db15f38 --- /dev/null +++ b/modules/claude/touchbar.py @@ -0,0 +1,76 @@ +"""claude: how full the Claude Code context window is. + +Each running status line (macarchy-core's `claude-statusline.sh`) drops its +context percentage in `$XDG_RUNTIME_DIR/macarchy-claude/` on every +render. This reads them back, so the sessions you are *not* looking at still +tell you when they are about to compact. +""" +import glob +import os +import time +import weakref + +from macarchy_touchbar.widgets import Button + +# A live status line rewrites its file on every render, so anything older than +# this belongs to a session that has exited. tmpfs clears the rest at logout. +FRESH = 90 + + +class Module: + DIR = None # tests point this somewhere writable + + def setup(self, api): + self.api = api + self.widgets = weakref.WeakSet() + api.widget("context", self.context) + api.every(5, self.refresh) + + def dir(self): + if self.DIR: + return self.DIR + runtime = os.environ.get("XDG_RUNTIME_DIR") + return os.path.join(runtime, "macarchy-claude") if runtime else None + + def state(self): + """(fullest session's percentage, live session count), sweeping the dead.""" + d = self.dir() + if not d: + return None, 0 + now, live = time.time(), [] + for path in glob.glob(os.path.join(d, "*")): + try: + if now - os.path.getmtime(path) > FRESH: + os.unlink(path) + continue + with open(path) as f: + live.append(int(f.read().strip())) + except (OSError, ValueError): + continue + return (max(live) if live else None), len(live) + + def refresh(self): + pct, count = self.state() + theme = self.api.theme + if pct is None: + text = tint = badge = None + else: + text = f"{pct} %" + if pct >= 85: + tint = theme.ACCENT_RED + elif pct >= 60: + tint = theme.ACCENT_ORANGE + else: + tint = theme.ACCENT_GREEN + # Only worth saying how many when it is more than the one in front of you. + badge = str(count) if count > 1 else None + for w in list(self.widgets): + if (w.text, w.tint, w.badge) != (text, tint, badge): + w.text, w.tint, w.badge = text, tint, badge + w.invalidate() + + def context(self, api, **p): + w = Button(api, icon="data_usage", **p) + self.widgets.add(w) + self.refresh() + return w diff --git a/tests/test_module_claude.py b/tests/test_module_claude.py new file mode 100644 index 0000000..84aa997 --- /dev/null +++ b/tests/test_module_claude.py @@ -0,0 +1,68 @@ +import os +import time + +from macarchy_touchbar.draw import Theme +from macarchy_touchbar.loop import EventLoop +from macarchy_touchbar.modules import Registry, ModuleHost, ModuleSpec +from tests.test_modules import Hooks + + +def load(tmp_path, **sessions): + d = tmp_path / "macarchy-claude" + d.mkdir(exist_ok=True) + for name, pct in sessions.items(): + (d / name).write_text(f"{pct}\n") + reg = Registry(); host = ModuleHost(EventLoop(), Hooks(), reg) + host.load(ModuleSpec("claude", "modules/claude/touchbar.py", 25)) + inst = host.modules["claude"]; type(inst).DIR = str(d) + return reg, host, inst, d + + +def button(reg, host, inst): + b = reg.factory("claude.context")(host.apis["claude"]) + inst.refresh() + return b + + +def test_percentage_and_tint_track_the_fullest_session(tmp_path): + reg, host, inst, d = load(tmp_path, s1=42) + b = button(reg, host, inst) + assert b.text == "42 %" and b.tint == Theme.ACCENT_GREEN and b.badge is None + + (d / "s1").write_text("70"); inst.refresh() + assert b.text == "70 %" and b.tint == Theme.ACCENT_ORANGE + + (d / "s1").write_text("91"); inst.refresh() + assert b.text == "91 %" and b.tint == Theme.ACCENT_RED + + +def test_a_fleet_shows_the_worst_one_and_counts_the_rest(tmp_path): + reg, host, inst, d = load(tmp_path, s1=12, s2=88, s3=40) + b = button(reg, host, inst) + assert b.text == "88 %" and b.badge == "3" + + +def test_dead_sessions_are_swept_and_the_button_goes_quiet(tmp_path): + reg, host, inst, d = load(tmp_path, s1=55) + b = button(reg, host, inst) + assert b.text == "55 %" + + stale = time.time() - 120 + os.utime(d / "s1", (stale, stale)) + inst.refresh() + assert not (d / "s1").exists() + assert b.text is None and b.tint is None and b.badge is None + + +def test_unreadable_files_are_ignored_not_fatal(tmp_path): + reg, host, inst, d = load(tmp_path, s1=30) + (d / "s2").write_text("not a number") + b = button(reg, host, inst) + assert b.text == "30 %" and b.badge is None + + +def test_no_runtime_dir_means_no_reading(tmp_path, monkeypatch): + reg, host, inst, d = load(tmp_path, s1=30) + type(inst).DIR = None + monkeypatch.delenv("XDG_RUNTIME_DIR", raising=False) + assert inst.state() == (None, 0)