feat: панель как фоновая служба без iTerm2 + цвет фона на клиенте #5
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
.superpowers/
|
||||
.bounded
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
88
tests/test_core.py
Normal file
88
tests/test_core.py
Normal file
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user