feat: client-side panel background with gear control (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7958314547
commit
2e8ab31bbd
@ -53,7 +53,9 @@ cd claude-panel
|
|||||||
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux;
|
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux;
|
||||||
- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен;
|
- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен;
|
||||||
- переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются;
|
- переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются;
|
||||||
- цвет токенов: жёлтый > 500k, красный > 700k.
|
- цвет токенов: жёлтый > 500k, красный > 700k;
|
||||||
|
- ⚙ в нижней строке — фон панели на этом устройстве (образцы или свой цвет, текст подстроится);
|
||||||
|
выбор хранится в этом браузере, «сброс» возвращает тему по умолчанию.
|
||||||
|
|
||||||
## Настройки
|
## Настройки
|
||||||
|
|
||||||
|
|||||||
@ -831,6 +831,33 @@ def _render_tree() -> str:
|
|||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# Фон панели выбирается на клиенте (⚙ внизу) и хранится в localStorage этого браузера/web view;
|
||||||
|
# текст и приглушённый текст подбираются под яркость фона, цвета статусов не меняются.
|
||||||
|
BG_PRESETS = ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3")
|
||||||
|
LIGHT_BG_TEXT = "#3b4a50" # текст на светлом фоне; на тёмном — fg темы по умолчанию
|
||||||
|
BG_STORAGE_KEY = "claude-panel-bg"
|
||||||
|
CLIENT_COLORS_JS = """function panelColors(bg) {
|
||||||
|
if (typeof bg !== 'string' || !/^#[0-9a-f]{6}$/i.test(bg)) return null;
|
||||||
|
bg = bg.toLowerCase();
|
||||||
|
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
|
||||||
|
const lin = c => (c /= 255) <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
const [r, g, b] = rgb(bg).map(lin); // относительная яркость по WCAG
|
||||||
|
const fg = 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.4 ? '__LIGHT__' : '__DARK__';
|
||||||
|
const f = rgb(fg), k = rgb(bg); // dim — как у темы: 60% текста, 40% фона
|
||||||
|
const dim = '#' + f.map((c, i) => Math.round(c * 0.6 + k[i] * 0.4).toString(16).padStart(2, '0')).join('');
|
||||||
|
return {bg, fg, dim};
|
||||||
|
}""".replace("__LIGHT__", LIGHT_BG_TEXT).replace("__DARK__", DEFAULT_THEME["fg"])
|
||||||
|
|
||||||
|
|
||||||
|
def render_bgctl() -> str:
|
||||||
|
swatches = "".join(f'<span class="sw" data-bg="{c}" title="{c}"><i style="background:{c}"></i></span>'
|
||||||
|
for c in BG_PRESETS)
|
||||||
|
return (f'<div id="bgctl"><div id="bgrow">{swatches}'
|
||||||
|
f'<input type="color" id="bg-custom" title="свой цвет" value="{DEFAULT_THEME["bg"]}">'
|
||||||
|
f'<span id="bg-reset">сброс</span></div>'
|
||||||
|
f'<span id="gear-home"><span id="gear" title="Фон панели">⚙</span></span></div>')
|
||||||
|
|
||||||
|
|
||||||
def render_page() -> str:
|
def render_page() -> str:
|
||||||
t = state["theme"]
|
t = state["theme"]
|
||||||
return f"""<!doctype html><html><head><meta charset="utf-8">
|
return f"""<!doctype html><html><head><meta charset="utf-8">
|
||||||
@ -857,7 +884,23 @@ def render_page() -> str:
|
|||||||
.fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }}
|
.fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }}
|
||||||
.sub {{ color:var(--dim); }}
|
.sub {{ color:var(--dim); }}
|
||||||
.split {{ margin:-2px 0 7px; }}
|
.split {{ margin:-2px 0 7px; }}
|
||||||
.foot {{ margin-top:2px; opacity:.8; }}
|
.foot {{ margin-top:2px; opacity:.8; display:flex; align-items:center; }}
|
||||||
|
.gear-slot {{ margin-left:auto; }}
|
||||||
|
/* фон на этом устройстве: ⚙ в строке «обновлено», образцы — под блоком лимитов */
|
||||||
|
#bgctl {{ flex:none; padding:0 10px; }}
|
||||||
|
#gear-home {{ display:flex; justify-content:flex-end; }}
|
||||||
|
#gear {{ display:inline-flex; align-items:center; justify-content:center; min-width:36px; min-height:36px;
|
||||||
|
margin:-10px -8px; cursor:pointer; color:var(--dim); font-size:15px; }}
|
||||||
|
#gear:hover {{ color:var(--fg); }}
|
||||||
|
#bgrow {{ display:none; gap:4px; align-items:center; flex-wrap:wrap; padding:0 0 8px; }}
|
||||||
|
#bgrow.open {{ display:flex; }}
|
||||||
|
.sw {{ display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; cursor:pointer; }}
|
||||||
|
.sw i {{ width:24px; height:24px; border-radius:50%; box-sizing:border-box;
|
||||||
|
border:1px solid color-mix(in srgb, var(--fg) 35%, transparent); }}
|
||||||
|
#bg-custom {{ width:36px; height:36px; padding:4px; border:none; background:none; cursor:pointer; }}
|
||||||
|
#bg-reset {{ display:inline-flex; align-items:center; min-height:36px; padding:0 6px; margin-left:auto;
|
||||||
|
color:var(--dim); cursor:pointer; }}
|
||||||
|
#bg-reset:hover {{ color:var(--fg); }}
|
||||||
.foot.stale {{ color:var(--yellow); }}
|
.foot.stale {{ color:var(--yellow); }}
|
||||||
.session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }}
|
.session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }}
|
||||||
.session:hover {{ background:color-mix(in srgb, var(--fg) 8%, transparent); }}
|
.session:hover {{ background:color-mix(in srgb, var(--fg) 8%, transparent); }}
|
||||||
@ -916,9 +959,21 @@ def render_page() -> str:
|
|||||||
.msg-h {{ color:var(--dim); margin-bottom:2px; white-space:normal; }}
|
.msg-h {{ color:var(--dim); margin-bottom:2px; white-space:normal; }}
|
||||||
.msg-h .user {{ color:var(--yellow); }} .msg-h .assistant {{ color:var(--green); }}
|
.msg-h .user {{ color:var(--yellow); }} .msg-h .assistant {{ color:var(--green); }}
|
||||||
.trimmed {{ color:var(--dim); font-style:italic; margin-bottom:8px; white-space:normal; }}
|
.trimmed {{ color:var(--dim); font-style:italic; margin-bottom:8px; white-space:normal; }}
|
||||||
</style></head><body>
|
</style>
|
||||||
|
<script>
|
||||||
|
{CLIENT_COLORS_JS}
|
||||||
|
function applyBg(hex) {{
|
||||||
|
const c = panelColors(hex), s = document.documentElement.style;
|
||||||
|
if (!c) {{ ['--bg', '--fg', '--dim'].forEach(p => s.removeProperty(p)); return false; }}
|
||||||
|
s.setProperty('--bg', c.bg); s.setProperty('--fg', c.fg); s.setProperty('--dim', c.dim);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
// до отрисовки — чтобы при каждой перезагрузке не мелькала тема по умолчанию
|
||||||
|
try {{ applyBg(localStorage.getItem('{BG_STORAGE_KEY}')); }} catch (e) {{}}
|
||||||
|
</script></head><body>
|
||||||
<div id="root">{render_tree()}</div>
|
<div id="root">{render_tree()}</div>
|
||||||
<div id="limits">{render_limits()}</div>
|
<div id="limits">{render_limits()}</div>
|
||||||
|
{render_bgctl()}
|
||||||
<div id="ov">
|
<div id="ov">
|
||||||
<div class="ov-head"><span class="ov-title"></span>
|
<div class="ov-head"><span class="ov-title"></span>
|
||||||
<span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span>
|
<span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span>
|
||||||
@ -937,7 +992,11 @@ def render_page() -> str:
|
|||||||
async function refreshLimits() {{
|
async function refreshLimits() {{
|
||||||
try {{
|
try {{
|
||||||
const r = await fetch('/limits', {{cache: 'no-store'}});
|
const r = await fetch('/limits', {{cache: 'no-store'}});
|
||||||
if (r.ok) document.getElementById('limits').innerHTML = await r.text();
|
if (r.ok) {{
|
||||||
|
document.getElementById('gear-home').appendChild(gear); // иначе innerHTML уничтожит ⚙
|
||||||
|
document.getElementById('limits').innerHTML = await r.text();
|
||||||
|
placeGear();
|
||||||
|
}}
|
||||||
}} catch (e) {{}}
|
}} catch (e) {{}}
|
||||||
}}
|
}}
|
||||||
setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется
|
setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется
|
||||||
@ -1015,7 +1074,37 @@ def render_page() -> str:
|
|||||||
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }});
|
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }});
|
||||||
setInterval(loadMsg, 2000);
|
setInterval(loadMsg, 2000);
|
||||||
|
|
||||||
// тема могла смениться — раз в минуту перезагружаем страницу (если сообщение не открыто)
|
// ---- фон панели на этом устройстве ----
|
||||||
|
const gear = document.getElementById('gear'), bgrow = document.getElementById('bgrow');
|
||||||
|
const bgCustom = document.getElementById('bg-custom');
|
||||||
|
function placeGear() {{
|
||||||
|
const slot = document.querySelector('#limits .gear-slot');
|
||||||
|
(slot || document.getElementById('gear-home')).appendChild(gear);
|
||||||
|
}}
|
||||||
|
function saveBg(hex) {{
|
||||||
|
if (!applyBg(hex)) return;
|
||||||
|
bgCustom.value = hex.toLowerCase();
|
||||||
|
try {{ localStorage.setItem('{BG_STORAGE_KEY}', hex.toLowerCase()); }} catch (e) {{}}
|
||||||
|
}}
|
||||||
|
gear.addEventListener('click', e => {{ e.stopPropagation(); bgrow.classList.toggle('open'); }});
|
||||||
|
bgrow.addEventListener('click', e => {{
|
||||||
|
e.stopPropagation();
|
||||||
|
const sw = e.target.closest('.sw');
|
||||||
|
if (sw) saveBg(sw.dataset.bg);
|
||||||
|
}});
|
||||||
|
bgCustom.addEventListener('input', () => saveBg(bgCustom.value));
|
||||||
|
document.getElementById('bg-reset').addEventListener('click', () => {{
|
||||||
|
applyBg(null);
|
||||||
|
bgCustom.value = '{DEFAULT_THEME["bg"]}';
|
||||||
|
try {{ localStorage.removeItem('{BG_STORAGE_KEY}'); }} catch (e) {{}}
|
||||||
|
}});
|
||||||
|
try {{
|
||||||
|
const saved = localStorage.getItem('{BG_STORAGE_KEY}');
|
||||||
|
if (panelColors(saved)) bgCustom.value = saved;
|
||||||
|
}} catch (e) {{}}
|
||||||
|
placeGear();
|
||||||
|
|
||||||
|
// раз в минуту перезагружаем страницу (если сообщение не открыто) — подхватить новую версию службы
|
||||||
setInterval(() => {{ if (!cur) location.reload(); }}, 60000);
|
setInterval(() => {{ if (!cur) location.reload(); }}, 60000);
|
||||||
</script>
|
</script>
|
||||||
</body></html>"""
|
</body></html>"""
|
||||||
@ -1238,7 +1327,8 @@ def render_limits() -> str:
|
|||||||
if state["usage_updated"]:
|
if state["usage_updated"]:
|
||||||
upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S")
|
upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S")
|
||||||
stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры
|
stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры
|
||||||
parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}</div>')
|
parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}'
|
||||||
|
f'<span class="gear-slot"></span></div>')
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
61
tests/test_appearance.py
Normal file
61
tests/test_appearance.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
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 Page(unittest.TestCase):
|
||||||
|
def test_page_applies_saved_bg_before_body(self):
|
||||||
|
html = ca.render_page()
|
||||||
|
body = html.index("<body>")
|
||||||
|
self.assertLess(html.index("claude-panel-bg"), body)
|
||||||
|
self.assertIn(ca.CLIENT_COLORS_JS, html)
|
||||||
|
self.assertIn("try {", html[:body])
|
||||||
|
|
||||||
|
def test_page_has_bg_controls(self):
|
||||||
|
html = ca.render_page()
|
||||||
|
for marker in ('id="bgctl"', 'id="gear"', 'id="bgrow"', 'type="color"', 'id="bg-reset"'):
|
||||||
|
self.assertIn(marker, html)
|
||||||
|
self.assertEqual(ca.BG_PRESETS, ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3"))
|
||||||
|
for hex_ in ca.BG_PRESETS:
|
||||||
|
self.assertIn(f'data-bg="{hex_}"', html)
|
||||||
|
limits_end = html.index('<div id="limits">') + len('<div id="limits">') + len(ca.render_limits())
|
||||||
|
self.assertGreater(html.index('id="bgctl"'), limits_end)
|
||||||
|
|
||||||
|
|
||||||
|
class Limits(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.saved = dict(ca.state)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
ca.state.clear()
|
||||||
|
ca.state.update(self.saved)
|
||||||
|
|
||||||
|
def test_limits_foot_has_gear_slot(self):
|
||||||
|
ca.state["usage_updated"], ca.state["usage_error"] = time.time(), None
|
||||||
|
self.assertIn('class="gear-slot"', ca.render_limits())
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("node"), "нет node")
|
||||||
|
class ClientColors(unittest.TestCase):
|
||||||
|
def test_panel_colors_js(self):
|
||||||
|
js = ca.CLIENT_COLORS_JS + (
|
||||||
|
"\nconsole.log(JSON.stringify([panelColors('#fdf6e3'), panelColors('#000000'),"
|
||||||
|
" panelColors('nope'), panelColors('#002b36')]))")
|
||||||
|
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, check=True).stdout
|
||||||
|
self.assertEqual(json.loads(out), [
|
||||||
|
{"bg": "#fdf6e3", "fg": "#3b4a50", "dim": "#898f8b"},
|
||||||
|
{"bg": "#000000", "fg": "#aacace", "dim": "#66797c"},
|
||||||
|
None,
|
||||||
|
{"bg": "#002b36", "fg": "#aacace", "dim": "#668a91"},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user