refactor: drop live iTerm2 coupling from panel server (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b67f74ddff
commit
4686cd1a8f
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
.bounded
|
.bounded
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@ -1,22 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Claude — панель для iTerm2 Toolbelt.
|
Claude — панель сессий Claude Code: страница на http://127.0.0.1:8766/, её же показывает
|
||||||
|
iTerm2 Toolbelt.
|
||||||
|
|
||||||
Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов —
|
Сверху (прокручивается): дерево активных сессий Claude Code и их субагентов —
|
||||||
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — вкладка сессии.
|
название, модель, контекст, worktree. Клик — последние сообщения, ⌘-клик — панель tmux сессии.
|
||||||
Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage.
|
Снизу (закреплено): лимиты подписки, разбивка по продуктам, баланс extra usage.
|
||||||
|
|
||||||
Данные пишет хук agents_panel_hook.sh (см. settings-hooks.json) в
|
Данные пишет хук agents_panel_hook.sh в ~/.cache/claude-agents/. Модели, если хук их
|
||||||
~/.cache/claude-agents/. Модели, если хук их не сообщил, берутся из транскриптов.
|
не сообщил, берутся из транскриптов.
|
||||||
|
|
||||||
Установка:
|
Установка — install.sh (хук, записи в ~/.claude/settings.json, этот файл, служба launchd).
|
||||||
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 удалить)
|
|
||||||
|
|
||||||
Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json.
|
Настройки (необязательно): ~/.config/claude-agents/config.json, пример — config.example.json.
|
||||||
Изменения подхватываются на лету, кроме port (нужен перезапуск скрипта).
|
Изменения подхватываются на лету, кроме port (нужен перезапуск службы).
|
||||||
|
|
||||||
Отладка: http://127.0.0.1:8766/raw — разобранное состояние.
|
Отладка: http://127.0.0.1:8766/raw — разобранное состояние.
|
||||||
Только стандартная библиотека.
|
Только стандартная библиотека.
|
||||||
@ -27,7 +24,6 @@ import html
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@ -58,9 +54,7 @@ CONTEXT_WARN = 500_000 # размер контекста, с которого
|
|||||||
CONTEXT_CRIT = 700_000 # … и красное
|
CONTEXT_CRIT = 700_000 # … и красное
|
||||||
HISTORY_MAX_CHARS = 30_000 # окно сообщений: сколько символов копить, старые удаляются
|
HISTORY_MAX_CHARS = 30_000 # окно сообщений: сколько символов копить, старые удаляются
|
||||||
|
|
||||||
FILE_MANAGER = "lf" # что открывать в новой панели по клику на worktree
|
FILE_MANAGER = "lf" # что открывать в новой панели tmux по клику на worktree
|
||||||
USE_PROFILE_THEME = True
|
|
||||||
THEME_VARIANT = "auto" # профиль с отдельными светлыми/тёмными цветами: auto — как в iTerm2, dark, light
|
|
||||||
|
|
||||||
# лимиты подписки и баланс (нижняя часть панели)
|
# лимиты подписки и баланс (нижняя часть панели)
|
||||||
USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429
|
USAGE_REFRESH_SEC = 120 # чаще не стоит: эндпоинт может отвечать 429
|
||||||
@ -78,8 +72,6 @@ CONFIG_KEYS = { # ключ в config.json -> (глобальная переме
|
|||||||
"keep_logs_days": ("KEEP_LOGS_SEC", lambda v: float(v) * 86400),
|
"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"),
|
"agent_dir": ("AGENT_DIR", lambda v: v if v in ("changed", "always", "never") else "changed"),
|
||||||
"file_manager": ("FILE_MANAGER", str),
|
"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_warn": ("CONTEXT_WARN", int),
|
||||||
"context_crit": ("CONTEXT_CRIT", int),
|
"context_crit": ("CONTEXT_CRIT", int),
|
||||||
"history_max_chars": ("HISTORY_MAX_CHARS", int),
|
"history_max_chars": ("HISTORY_MAX_CHARS", int),
|
||||||
@ -112,14 +104,14 @@ def load_config():
|
|||||||
state["config_error"] = None
|
state["config_error"] = None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
state["config_error"] = f"config.json: {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",
|
"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,
|
"usage": None, "usage_error": None, "usage_updated": None,
|
||||||
"balance": None, "balance_error": None, "balance_via": None}
|
"balance": None, "balance_error": None, "balance_via": None}
|
||||||
_app = None
|
|
||||||
_server = None
|
_server = None
|
||||||
|
|
||||||
|
|
||||||
@ -1248,27 +1240,7 @@ def render_limits() -> str:
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ---------- связь с iTerm2 ----------
|
# ---------- действия по клику (через tmux) ----------
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def known_roots():
|
def known_roots():
|
||||||
roots = set()
|
roots = set()
|
||||||
@ -1286,13 +1258,15 @@ _last_open = {} # path -> time последнего открытия (защи
|
|||||||
|
|
||||||
|
|
||||||
async def open_worktree(uuid, path):
|
async def open_worktree(uuid, path):
|
||||||
"""Делит панель сессии по вертикали и сразу запускает в новой панели файловый менеджер.
|
"""Делит панель tmux сессии по горизонтали и сразу запускает в новой панели файловый менеджер.
|
||||||
|
|
||||||
Команда и папка задаются настройками новой панели, а не вводом текста в оболочку:
|
Команда передаётся tmux при создании панели, а не вводом текста в оболочку:
|
||||||
так не возникает гонки «текст отправлен раньше, чем оболочка готова»,
|
так не возникает гонки «текст отправлен раньше, чем оболочка готова»,
|
||||||
и панель закрывается сама, когда выходите из lf."""
|
и панель закрывается сама, когда выходите из lf. Сессии вне tmux не поддерживаются."""
|
||||||
if path not in known_roots() or not os.path.isdir(path):
|
if path not in known_roots() or not os.path.isdir(path):
|
||||||
return # открываем только папки, которые панель сама показала
|
return # открываем только папки, которые панель сама показала
|
||||||
|
if not (uuid and uuid.startswith("tmux:") and uuid in known_targets()):
|
||||||
|
return
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - _last_open.get(path, 0) < 3:
|
if now - _last_open.get(path, 0) < 3:
|
||||||
return # повторный клик/запрос — уже открываем
|
return # повторный клик/запрос — уже открываем
|
||||||
@ -1300,28 +1274,10 @@ async def open_worktree(uuid, path):
|
|||||||
state["open_log"] = (state.get("open_log") or [])[-9:] + [
|
state["open_log"] = (state.get("open_log") or [])[-9:] + [
|
||||||
{"t": time.strftime("%H:%M:%S"), "uuid": uuid, "path": path}]
|
{"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)
|
sock, pane = _tmux_target(uuid)
|
||||||
cmd = f"exec {FILE_MANAGER}"
|
cmd = f"exec {FILE_MANAGER}"
|
||||||
await asyncio.get_running_loop().run_in_executor(
|
await asyncio.get_running_loop().run_in_executor(
|
||||||
None, lambda: _tmux(sock, "split-window", "-h", "-t", pane, "-c", path, cmd))
|
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()
|
|
||||||
|
|
||||||
|
|
||||||
def known_targets():
|
def known_targets():
|
||||||
@ -1340,41 +1296,27 @@ def _tmux(sock, *args):
|
|||||||
|
|
||||||
|
|
||||||
async def activate(uuid):
|
async def activate(uuid):
|
||||||
if uuid not in known_targets():
|
if uuid not in known_targets() or not uuid.startswith("tmux:"):
|
||||||
return # только то, что панель сама показала
|
return # только сессии в tmux, которые панель сама показала
|
||||||
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)
|
sock, pane = _tmux_target(uuid)
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
for args in (("switch-client", "-t", pane), ("select-window", "-t", pane),
|
for args in (("switch-client", "-t", pane), ("select-window", "-t", pane),
|
||||||
("select-pane", "-t", pane)):
|
("select-pane", "-t", pane)):
|
||||||
await loop.run_in_executor(None, lambda a=args: _tmux(sock, *a))
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def refresher(connection):
|
async def refresher():
|
||||||
last_cleanup = 0
|
last_cleanup = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
load_config()
|
load_config()
|
||||||
live = await live_sessions(connection) if connection is not None else None
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
sessions = await loop.run_in_executor(None, load_sessions)
|
sessions = await loop.run_in_executor(None, load_sessions)
|
||||||
alive_now = [x for x in sessions if not x["ended"]]
|
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])
|
procs = await loop.run_in_executor(None, running_processes, [x["pid"] for x in alive_now])
|
||||||
tmux = await loop.run_in_executor(
|
tmux = await loop.run_in_executor(
|
||||||
None, tmux_panes, {x["tmux_socket"] for x in alive_now if x["tmux_socket"]})
|
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
|
state["error"] = None
|
||||||
if time.time() - last_cleanup > 3600:
|
if time.time() - last_cleanup > 3600:
|
||||||
cleanup_logs()
|
cleanup_logs()
|
||||||
@ -1393,49 +1335,6 @@ def cleanup_logs():
|
|||||||
pass
|
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 ----------
|
# ---------- HTTP ----------
|
||||||
|
|
||||||
async def handle(reader, writer):
|
async def handle(reader, writer):
|
||||||
@ -1463,7 +1362,7 @@ async def handle(reader, writer):
|
|||||||
body = "ok"
|
body = "ok"
|
||||||
elif url.path == "/raw":
|
elif url.path == "/raw":
|
||||||
body = json.dumps({"log_dir": LOG_DIR, "open_log": state.get("open_log"),
|
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"],
|
"usage_error": state["usage_error"], "balance_via": state["balance_via"],
|
||||||
"balance_error": state["balance_error"],
|
"balance_error": state["balance_error"],
|
||||||
"log_files": sorted(os.path.basename(p) for p in
|
"log_files": sorted(os.path.basename(p) for p in
|
||||||
@ -1488,11 +1387,9 @@ async def main(connection):
|
|||||||
global _server
|
global _server
|
||||||
load_config()
|
load_config()
|
||||||
_server = await asyncio.start_server(handle, "127.0.0.1", PORT)
|
_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(usage_poller())
|
||||||
asyncio.create_task(balance_poller())
|
asyncio.create_task(balance_poller())
|
||||||
if USE_PROFILE_THEME:
|
|
||||||
asyncio.create_task(theme_poller(connection))
|
|
||||||
await iterm2.tool.async_register_web_view_tool(
|
await iterm2.tool.async_register_web_view_tool(
|
||||||
connection,
|
connection,
|
||||||
display_name="Claude",
|
display_name="Claude",
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
"agent_idle_min": 15,
|
"agent_idle_min": 15,
|
||||||
"poll_sec": 1.5,
|
"poll_sec": 1.5,
|
||||||
"keep_logs_days": 2,
|
"keep_logs_days": 2,
|
||||||
"use_profile_theme": true,
|
|
||||||
"port": 8766,
|
"port": 8766,
|
||||||
"context_warn": 500000,
|
"context_warn": 500000,
|
||||||
"context_crit": 700000,
|
"context_crit": 700000,
|
||||||
@ -13,6 +12,5 @@
|
|||||||
"usage_refresh_sec": 120,
|
"usage_refresh_sec": 120,
|
||||||
"balance_refresh_sec": 600,
|
"balance_refresh_sec": 600,
|
||||||
"show_breakdown": true,
|
"show_breakdown": true,
|
||||||
"show_balance": true,
|
"show_balance": true
|
||||||
"theme_variant": "auto"
|
|
||||||
}
|
}
|
||||||
|
|||||||
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