From 4686cd1a8fb02cfd4b8511ef95d0ae2a3ae65c03 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:53:36 +0400 Subject: [PATCH] refactor: drop live iTerm2 coupling from panel server (#4) Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + files/claude_agents.py | 169 ++++++++------------------------------ files/config.example.json | 4 +- tests/test_core.py | 88 ++++++++++++++++++++ 4 files changed, 123 insertions(+), 139 deletions(-) create mode 100644 tests/test_core.py diff --git a/.gitignore b/.gitignore index 4b7e182..533a1cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .superpowers/ .bounded .DS_Store +__pycache__/ diff --git a/files/claude_agents.py b/files/claude_agents.py index b0feb58..6b1e86c 100644 --- a/files/claude_agents.py +++ b/files/claude_agents.py @@ -1,22 +1,19 @@ #!/usr/bin/env python3 """ -Claude — панель для iTerm2 Toolbelt. +Claude — панель сессий Claude Code: страница на http://127.0.0.1:8766/, её же показывает +iTerm2 Toolbelt. Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов — -название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — вкладка сессии. +название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — панель tmux сессии. Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage. -Данные пишет хук agents_panel_hook.sh (см. settings-hooks.json) в -~/.cache/claude-agents/. Модели, если хук их не сообщил, берутся из транскриптов. +Данные пишет хук agents_panel_hook.sh в ~/.cache/claude-agents/. Модели, если хук их +не сообщил, берутся из транскриптов. -Установка: - 1. Хук: скопировать agents_panel_hook.sh в ~/.claude/hooks/ и добавить блок - "hooks" из settings-hooks.json в ~/.claude/settings.json - 2. Панель: этот файл — в ~/Library/Application Support/iTerm2/Scripts/AutoLaunch/ - 3. Toolbelt → Claude (старый скрипт лимитов claude_usage.py из AutoLaunch удалить) +Установка — install.sh (хук, записи в ~/.claude/settings.json, этот файл, служба launchd). Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json. -Изменения подхватываются на лету, кроме port (нужен перезапуск скрипта). +Изменения подхватываются на лету, кроме port (нужен перезапуск службы). Отладка: http://127.0.0.1:8766/raw — разобранное состояние. Только стандартная библиотека. @@ -27,7 +24,6 @@ import html import json import os import re -import shlex import shutil import subprocess import time @@ -58,9 +54,7 @@ CONTEXT_WARN = 500_000 # размер контекста, с которого CONTEXT_CRIT = 700_000 # … и красное HISTORY_MAX_CHARS = 30_000 # окно сообщений: сколько символов копить, старые удаляются -FILE_MANAGER = "lf" # что открывать в новой панели по клику на worktree -USE_PROFILE_THEME = True -THEME_VARIANT = "auto" # профиль с отдельными светлыми/тёмными цветами: auto — как в iTerm2, dark, light +FILE_MANAGER = "lf" # что открывать в новой панели tmux по клику на worktree # лимиты подписки и баланс (нижняя часть панели) USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429 @@ -78,8 +72,6 @@ CONFIG_KEYS = { # ключ в config.json -> (глобальная переме "keep_logs_days": ("KEEP_LOGS_SEC", lambda v: float(v) * 86400), "agent_dir": ("AGENT_DIR", lambda v: v if v in ("changed", "always", "never") else "changed"), "file_manager": ("FILE_MANAGER", str), - "use_profile_theme": ("USE_PROFILE_THEME", bool), - "theme_variant": ("THEME_VARIANT", lambda v: v if v in ("auto", "dark", "light") else "auto"), "context_warn": ("CONTEXT_WARN", int), "context_crit": ("CONTEXT_CRIT", int), "history_max_chars": ("HISTORY_MAX_CHARS", int), @@ -112,14 +104,14 @@ def load_config(): state["config_error"] = None except Exception as e: state["config_error"] = f"config.json: {e}" -DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a90", +# Тема по умолчанию — как профиль iTerm2 «Chat»; фон меняется на клиенте шестерёнкой +DEFAULT_THEME = {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91", "green": "#859900", "yellow": "#b58900", "red": "#dc322f", - "font": "'MesloLGL Nerd Font', Menlo, ui-monospace, monospace", "size": 12} + "font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13} -state = {"theme": dict(DEFAULT_THEME), "live": None, "tree": [], "error": None, +state = {"theme": dict(DEFAULT_THEME), "tree": [], "error": None, "usage": None, "usage_error": None, "usage_updated": None, "balance": None, "balance_error": None, "balance_via": None} -_app = None _server = None @@ -1248,27 +1240,7 @@ def render_limits() -> str: return "".join(parts) -# ---------- связь с iTerm2 ---------- - -async def live_sessions(connection): - global _app - if _app is None: - _app = await iterm2.async_get_app(connection) - live = {} - for w in _app.terminal_windows: - for tab in w.tabs: - for sess in tab.sessions: - pane = None - try: - name = await sess.async_get_variable("name") - job = await sess.async_get_variable("jobName") - pane = await sess.async_get_variable("tmuxWindowPane") # tmux -CC: номер панели - except Exception: - name, job = sess.name, "" - live[sess.session_id] = {"name": name or "", "job": job or "", - "tmux_pane": f"%{pane}" if pane is not None else None} - return live - +# ---------- действия по клику (через tmux) ---------- def known_roots(): roots = set() @@ -1286,13 +1258,15 @@ _last_open = {} # path -> time последнего открытия (защи async def open_worktree(uuid, path): - """Делит панель сессии по вертикали и сразу запускает в новой панели файловый менеджер. + """Делит панель tmux сессии по горизонтали и сразу запускает в новой панели файловый менеджер. - Команда и папка задаются настройками новой панели, а не вводом текста в оболочку: + Команда передаётся tmux при создании панели, а не вводом текста в оболочку: так не возникает гонки «текст отправлен раньше, чем оболочка готова», - и панель закрывается сама, когда выходите из lf.""" + и панель закрывается сама, когда выходите из lf. Сессии вне tmux не поддерживаются.""" if path not in known_roots() or not os.path.isdir(path): return # открываем только папки, которые панель сама показала + if not (uuid and uuid.startswith("tmux:") and uuid in known_targets()): + return now = time.time() if now - _last_open.get(path, 0) < 3: return # повторный клик/запрос — уже открываем @@ -1300,28 +1274,10 @@ async def open_worktree(uuid, path): state["open_log"] = (state.get("open_log") or [])[-9:] + [ {"t": time.strftime("%H:%M:%S"), "uuid": uuid, "path": path}] - if uuid and uuid.startswith("tmux:") and uuid in known_targets(): - sock, pane = _tmux_target(uuid) - cmd = f"exec {FILE_MANAGER}" - await asyncio.get_running_loop().run_in_executor( - None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd)) - return - if _app is None: - return - sess = _app.get_session_by_id(uuid) if uuid and not uuid.startswith("tmux:") else None - if sess is None: - w = _app.current_terminal_window - sess = w.current_tab.current_session if w else None - if sess is None: - return - prof = iterm2.LocalWriteOnlyProfile() - prof.set_use_custom_command("Yes") - # через login-оболочку, чтобы PATH был как обычно (Homebrew и т.п.) - shell = os.environ.get("SHELL") or "/bin/zsh" - prof.set_command(f"{shell} -lc {shlex.quote('cd ' + shlex.quote(path) + ' && exec ' + FILE_MANAGER)}") - new = await sess.async_split_pane(vertical=True, profile_customizations=prof) - if new is not None: - await new.async_activate() + sock, pane = _tmux_target(uuid) + cmd = f"exec {FILE_MANAGER}" + await asyncio.get_running_loop().run_in_executor( + None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd)) def known_targets(): @@ -1340,41 +1296,27 @@ def _tmux(sock, *args): async def activate(uuid): - if uuid not in known_targets(): - return # только то, что панель сама показала - twin = (state.get("tmux_twins") or {}).get(uuid) - if twin and _app is not None: # tmux -CC — переключаемся средствами iTerm2: вкладка и окно спереди - sess = _app.get_session_by_id(twin) - if sess: - await sess.async_activate(select_tab=True, order_window_front=True) - return - if uuid.startswith("tmux:"): - sock, pane = _tmux_target(uuid) - loop = asyncio.get_running_loop() - for args in (("switch-client", "-t", pane), ("select-window", "-t", pane), - ("select-pane", "-t", pane)): - await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a)) - return - if _app is None: - return - sess = _app.get_session_by_id(uuid) - if sess: - await sess.async_activate(select_tab=True, order_window_front=True) + if uuid not in known_targets() or not uuid.startswith("tmux:"): + return # только сессии в tmux, которые панель сама показала + sock, pane = _tmux_target(uuid) + loop = asyncio.get_running_loop() + for args in (("switch-client", "-t", pane), ("select-window", "-t", pane), + ("select-pane", "-t", pane)): + await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a)) -async def refresher(connection): +async def refresher(): last_cleanup = 0 while True: try: load_config() - live = await live_sessions(connection) if connection is not None else None loop = asyncio.get_running_loop() sessions = await loop.run_in_executor(None, load_sessions) alive_now = [x for x in sessions if not x["ended"]] procs = await loop.run_in_executor(None, running_processes, [x["pid"] for x in alive_now]) tmux = await loop.run_in_executor( None, tmux_panes, {x["tmux_socket"] for x in alive_now if x["tmux_socket"]}) - state["tree"] = build_tree(sessions, live, procs, tmux) + state["tree"] = build_tree(sessions, None, procs, tmux) state["error"] = None if time.time() - last_cleanup > 3600: cleanup_logs() @@ -1393,49 +1335,6 @@ def cleanup_logs(): pass -def _hex(c) -> str: - return "#%02x%02x%02x" % (round(c.red), round(c.green), round(c.blue)) - - -def _mix(c1, c2, k: float) -> str: - return "#%02x%02x%02x" % tuple(round(a * k + b * (1 - k)) for a, b in - [(c1.red, c2.red), (c1.green, c2.green), (c1.blue, c2.blue)]) - - -async def theme_poller(connection): - """Цвета и шрифт — из профиля текущей сессии. Если в профиле включены отдельные цвета - для светлой и тёмной темы, берётся вариант, который iTerm2 сейчас показывает.""" - while True: - try: - app = _app or await iterm2.async_get_app(connection) - p = await app.current_terminal_window.current_tab.current_session.async_get_profile() - suffix = "" - if getattr(p, "use_separate_colors_for_light_and_dark_mode", False): - mode = [THEME_VARIANT] if THEME_VARIANT != "auto" else await app.async_get_theme() - suffix = " (Dark)" if "dark" in mode else " (Light)" - - def color(key): - return p.get_color_with_key(key + suffix) or p.get_color_with_key(key) - - theme = dict(DEFAULT_THEME) - bg, fg = color("Background Color"), color("Foreground Color") - if bg is not None and fg is not None: - theme.update(bg=_hex(bg), fg=_hex(fg), dim=_mix(fg, bg, 0.6)) - for name, idx in (("red", 1), ("green", 2), ("yellow", 3)): - c = color(f"Ansi {idx} Color") - if c is not None: - theme[name] = _hex(c) - font = (p.normal_font or "").rsplit(" ", 1) - if len(font) == 2: - theme["font"] = f"'{font[0].split('-')[0]}', '{font[0]}', ui-monospace, monospace" - theme["size"] = min(int(float(font[1])), 13) - theme["source"] = f"profile {p.name!r}{suffix or ''}" - state["theme"] = theme - except Exception as e: - state["theme_error"] = str(e) - await asyncio.sleep(30) - - # ---------- HTTP ---------- async def handle(reader, writer): @@ -1463,7 +1362,7 @@ async def handle(reader, writer): body = "ok" elif url.path == "/raw": body = json.dumps({"log_dir": LOG_DIR, "open_log": state.get("open_log"), - "theme": state["theme"], "theme_error": state.get("theme_error"), + "theme": state["theme"], "usage_error": state["usage_error"], "balance_via": state["balance_via"], "balance_error": state["balance_error"], "log_files": sorted(os.path.basename(p) for p in @@ -1488,11 +1387,9 @@ async def main(connection): global _server load_config() _server = await asyncio.start_server(handle, "127.0.0.1", PORT) - asyncio.create_task(refresher(connection)) + asyncio.create_task(refresher()) asyncio.create_task(usage_poller()) asyncio.create_task(balance_poller()) - if USE_PROFILE_THEME: - asyncio.create_task(theme_poller(connection)) await iterm2.tool.async_register_web_view_tool( connection, display_name="Claude", diff --git a/files/config.example.json b/files/config.example.json index 23186c9..083cb4c 100644 --- a/files/config.example.json +++ b/files/config.example.json @@ -5,7 +5,6 @@ "agent_idle_min": 15, "poll_sec": 1.5, "keep_logs_days": 2, - "use_profile_theme": true, "port": 8766, "context_warn": 500000, "context_crit": 700000, @@ -13,6 +12,5 @@ "usage_refresh_sec": 120, "balance_refresh_sec": 600, "show_breakdown": true, - "show_balance": true, - "theme_variant": "auto" + "show_balance": true } diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..334d5aa --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,88 @@ +import asyncio +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files")) +import claude_agents as ca # noqa: E402 + + +class DefaultTheme(unittest.TestCase): + def test_default_theme_matches_chat_profile(self): + self.assertEqual(ca.DEFAULT_THEME, { + "bg": "#002b36", "fg": "#aacace", "dim": "#668a91", + "green": "#859900", "yellow": "#b58900", "red": "#dc322f", + "font": "'MesloLGLNF', 'MesloLGLNF-Regular', ui-monospace, monospace", "size": 13}) + + +class NoITermCoupling(unittest.TestCase): + def test_iterm_coupling_removed(self): + for name in ("theme_poller", "live_sessions", "USE_PROFILE_THEME", "THEME_VARIANT", + "_app", "_mix", "_hex"): + self.assertFalse(hasattr(ca, name), name) + self.assertNotIn("use_profile_theme", ca.CONFIG_KEYS) + self.assertNotIn("theme_variant", ca.CONFIG_KEYS) + + +class Config(unittest.TestCase): + def setUp(self): + self.saved = (ca.CONFIG_PATH, ca.POLL_SEC, ca._config_mtime) + self.tmp = tempfile.TemporaryDirectory() + + def tearDown(self): + ca.CONFIG_PATH, ca.POLL_SEC, ca._config_mtime = self.saved + self.tmp.cleanup() + + def test_old_theme_keys_ignored(self): + path = os.path.join(self.tmp.name, "config.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"use_profile_theme": False, "theme_variant": "dark", "poll_sec": 2}, f) + ca.CONFIG_PATH, ca._config_mtime = path, None + ca.load_config() + self.assertIsNone(ca.state.get("config_error")) + self.assertEqual(ca.POLL_SEC, 2.0) + + +class Actions(unittest.TestCase): + def setUp(self): + self.calls = [] + self.saved = (ca._tmux, ca.known_roots, ca.FILE_MANAGER, ca.state["tree"]) + ca._tmux = lambda sock, *args: self.calls.append((sock, *args)) + ca._last_open.clear() + self.tmp = tempfile.TemporaryDirectory() + + def tearDown(self): + ca._tmux, ca.known_roots, ca.FILE_MANAGER, ca.state["tree"] = self.saved + self.tmp.cleanup() + + def test_activate_tmux_target(self): + ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}] + asyncio.run(ca.activate("tmux:/tmp/s|%3")) + self.assertEqual(self.calls, [("/tmp/s", "switch-client", "-t", "%3"), + ("/tmp/s", "select-window", "-t", "%3"), + ("/tmp/s", "select-pane", "-t", "%3")]) + + def test_activate_non_tmux_is_noop(self): + ca.state["tree"] = [{"uuid": "w0t0p0:ABC", "agents": []}] + asyncio.run(ca.activate("w0t0p0:ABC")) + self.assertEqual(self.calls, []) + + def test_open_worktree_non_tmux_is_noop(self): + ca.state["tree"] = [{"uuid": "w0t0p0:ABC", "agents": []}] + ca.known_roots = lambda: {self.tmp.name} + asyncio.run(ca.open_worktree("w0t0p0:ABC", self.tmp.name)) + self.assertEqual(self.calls, []) + + def test_open_worktree_tmux_splits(self): + ca.state["tree"] = [{"uuid": "tmux:/tmp/s|%3", "agents": []}] + ca.known_roots = lambda: {self.tmp.name} + ca.FILE_MANAGER = "lf" + asyncio.run(ca.open_worktree("tmux:/tmp/s|%3", self.tmp.name)) + self.assertEqual(self.calls, [("/tmp/s", "split-window", "-h", "-t", "%3", + "-c", self.tmp.name, "exec lf")]) + + +if __name__ == "__main__": + unittest.main()