diff --git "a/panel.py" "b/panel.py"
new file mode 100644--- /dev/null
+++ "b/panel.py"
@@ -0,0 +1,2449 @@
+#!/usr/bin/env python3
+"""Claude Code Control Panel — GTK3 Desktop App.
+
+A native desktop panel with sliders, toggles, dropdowns, session browser,
+and shortcuts to the entire Vibe Coding setup.
+
+Performance: Fixed label widgets (no rebuild), separate timers for Hub/Monitor,
+data fetching via GLib.idle_add for non-blocking UI.
+"""
+
+import fcntl
+import logging
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+import gi
+
+logging.basicConfig(
+ filename=str(Path.home() / ".cache" / "claude-panel.log"),
+ level=logging.INFO,
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
+)
+log = logging.getLogger("claude_panel")
+
+gi.require_version("Gtk", "3.0")
+gi.require_version("AyatanaAppIndicator3", "0.1")
+from gi.repository import Gtk, GLib, Gdk, Pango, Gio, AyatanaAppIndicator3
+
+from config_io import (
+ read_settings,
+ write_settings,
+ read_hook_list,
+ read_coaching_rate_limit,
+ write_coaching_rate_limit,
+)
+from monitor import (
+ get_daily_cost,
+ get_top_tools,
+ get_active_sessions,
+ get_recent_sessions,
+ get_missed_skills_summary,
+ get_usage_timeline,
+ get_provider_costs,
+ get_anthropic_session_cost,
+ get_skill_usage,
+ get_sidecar_status,
+ format_cost,
+)
+from log_viewer import build_logs_tab, refresh_logs
+from session_browser import build_sessions_tab, refresh_sessions, _scan_all_sessions
+from process_manager import build_processes_tab, refresh_processes
+from theme import build_css, setup_theme_watcher, get_palette
+from utils import idle_once
+from swarm_tab import build_swarm_tab, refresh_swarm
+from shortcut_counter_tab import build_shortcut_counter_tab, refresh_shortcut_counter
+from project_dashboard_tab import build_project_dashboard_tab, refresh_project_dashboard
+from event_tab import build_events_tab, refresh_events
+from brightness_tab import build_brightness_tab, refresh_brightness
+
+
+# ---------------------------------------------------------------------------
+# Monitor Tab helpers
+# ---------------------------------------------------------------------------
+_PROVIDER_COLORS = {"anthropic": "mauve", "gemini": "teal", "minimax": "peach"}
+
+
+def _hex_rgb(hex_color: str) -> tuple[float, float, float]:
+ """Convert #rrggbb to (r, g, b) floats 0-1."""
+ return (
+ int(hex_color[1:3], 16) / 255,
+ int(hex_color[3:5], 16) / 255,
+ int(hex_color[5:7], 16) / 255,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Shortcuts: clickable links to docs, projects, tools
+# ---------------------------------------------------------------------------
+# Categories for color-coding: "service", "docs", "folder", "config"
+SHORTCUTS = [
+ # --- Services (Blue #89b4fa) — interactive apps, APIs, dashboards ---
+ {
+ "label": "MAS Dashboard",
+ "icon": "utilities-system-monitor",
+ "path": "~/ClaudesReich/Visualisierung/",
+ "tooltip": "Multi-Agent System — War Room, Timeline, Cost Analysis",
+ "command": "cd ~/ClaudesReich/Visualisierung && python3 -m streamlit run app.py --server.port 8501 2>/dev/null & sleep 2 && xdg-open http://localhost:8501",
+ "category": "service",
+ },
+ {
+ "label": "Session Browser",
+ "icon": "system-search",
+ "path": "~/ClaudesReich/SessionBrowser/",
+ "tooltip": "Wo war ich? — Session-Uebersicht + Wiedereinstieg",
+ "command": "cd ~/ClaudesReich/SessionBrowser && python3 -m streamlit run app.py --server.port 8502 2>/dev/null & sleep 2 && xdg-open http://localhost:8502",
+ "category": "service",
+ },
+ {
+ "label": "Florian TTS",
+ "icon": "audio-speakers",
+ "path": "~/Projekte/MyAIGame/voicemode-edge-tts/",
+ "tooltip": "Edge TTS API — localhost:5050/docs",
+ "command": "xdg-open http://localhost:5050/docs",
+ "category": "service",
+ },
+ {
+ "label": "MultiKanal",
+ "icon": "applications-science",
+ "path": "~/AiSystemForVibeCoding/",
+ "tooltip": "MultiKanal Agent Daemon — Narration + TTS",
+ "command": "xdg-open http://localhost:8000/docs",
+ "category": "service",
+ },
+ {
+ "label": "Swarm Dashboard",
+ "icon": "network-workgroup",
+ "path": "~/Projekte/AgentSwarmDashboard/",
+ "tooltip": "Agent Swarm Dashboard — Team Graph, Kanban, Feed",
+ "command": "xdg-open http://localhost:5111",
+ "category": "service",
+ },
+ # --- Docs (Yellow #f9e2af) — reference, guides, knowledge ---
+ {
+ "label": "CLAUDE.md",
+ "icon": "document-properties",
+ "path": "~/.claude/CLAUDE.md",
+ "tooltip": "Hauptkonfiguration — alle Regeln",
+ "category": "docs",
+ },
+ {
+ "label": "Skills",
+ "icon": "help-contents",
+ "path": "~/.claude/Skilluebersicht.md",
+ "tooltip": "Alle Skills mit Beschreibung + Kosten",
+ "category": "docs",
+ },
+ {
+ "label": "Wie arbeite ich",
+ "icon": "dialog-information",
+ "path": "~/.claude/WieArbeitestDuMitSamuel.md",
+ "tooltip": "Samuel-Workflow, Plan Mode, Anticipation",
+ "category": "docs",
+ },
+ {
+ "label": "Fehler",
+ "icon": "dialog-warning",
+ "path": "~/.claude/WelcheFehlerVermeiden.md",
+ "tooltip": "Top 10 Fehler + Fix Chains",
+ "category": "docs",
+ },
+ {
+ "label": "Coaching Log",
+ "icon": "appointment-new",
+ "path": "~/.claude/hooks/coaching/coaching_log.md",
+ "tooltip": "Git/Test Micro-Lessons Log",
+ "category": "docs",
+ },
+ # --- Folders (Green #a6e3a1) — project navigation ---
+ {
+ "label": "Projekte",
+ "icon": "folder",
+ "path": "~/Projekte/",
+ "tooltip": "Projektordner oeffnen",
+ "category": "folder",
+ },
+ {
+ "label": "ClaudesReich",
+ "icon": "user-home",
+ "path": "~/ClaudesReich/",
+ "tooltip": "Alles was Claude gehoert",
+ "category": "folder",
+ },
+ {
+ "label": "MyAIGame",
+ "icon": "applications-games",
+ "path": "~/Projekte/MyAIGame/",
+ "tooltip": "Multikanal + TUI + Hooks",
+ "category": "folder",
+ },
+ {
+ "label": "Hook Plugins",
+ "icon": "preferences-plugin",
+ "path": "~/Dokumente/Pläne/ClaudeCodeWorks/plugins/claude-hook/hooks/",
+ "tooltip": "Alle Hook-Scripts",
+ "category": "folder",
+ },
+ {
+ "label": "Plaene",
+ "icon": "text-x-generic",
+ "path": "~/Dokumente/Pläne/",
+ "tooltip": "Alle Plaene + ClaudeCodeWorks Plugins",
+ "category": "folder",
+ },
+ {
+ "label": "Session Archiv",
+ "icon": "folder-documents",
+ "path": "~/ClaudesReich/EveryClaudeCodeSessionfromeverydeviceever/",
+ "tooltip": "Alle Sessions von allen Geraeten",
+ "category": "folder",
+ },
+ {
+ "label": "Archiv Plaene",
+ "icon": "folder-visiting",
+ "path": "~/ClaudesReich/ArchivPläne/",
+ "tooltip": "Archivierte Session-Plaene",
+ "category": "folder",
+ },
+ # --- Config (Mauve #cba6f7) ---
+ {
+ "label": "Settings",
+ "icon": "preferences-system",
+ "path": "~/.claude/settings.json",
+ "tooltip": "settings.json — Hooks, MCP, Env",
+ "category": "config",
+ },
+]
+
+# ---------------------------------------------------------------------------
+# Skills: complete list organized by category
+# ---------------------------------------------------------------------------
+SKILLS = {
+ "Coding & Delegation": [
+ ("/chef", "~$3", "Delegation an OpenCode MCP mit Context-Gathering"),
+ ("/chef-lite", "~$3", "Direkter OpenCode-Call ohne Context-Gathering"),
+ ("/chef-async", "~$3", "Wie /chef aber non-blocking"),
+ ("/chef-subagent", "~$3.25", "Haiku-SubAgent der OpenCode steuert"),
+ ("/batch", "~$3", "Mehrere Tasks in EINEN opencode_run Call"),
+ ("/auto", "variabel", "Smart auto-delegation — waehlt beste Methode"),
+ ("/codex", "~$0.05", "Codex Sparringspartner — Zweitmeinung oder Review"),
+ ],
+ "Research & Analyse": [
+ ("/research", "~$0.10", "Gemini MCP Deep Web Research"),
+ ("/research-subagent", "~$0.25", "Gemini-Research als Haiku-SubAgent"),
+ ("/research-swarm", "~$0.30", "3 parallele Gemini-Agents fuer Mega-Research"),
+ ("/swarm", "~$0.75", "3 Haiku-Agents fuer Dokumentanalyse"),
+ ("/litellm", "~$0.05", "1-shot Call an cheap LLM — kein Multi-Agent"),
+ ],
+ "Testing": [
+ ("/test", "~$3", "Cascading Test Pipeline via OpenCode + Auto-Fix"),
+ ("/test-crew", "~$0.27", "Gemini plant Tests, OpenCode fuehrt aus + fixt"),
+ ],
+ "CLI ($0)": [
+ ("/cli lint", "$0", "Auto-detect + run Linter"),
+ ("/cli format", "$0", "Auto-detect + run Formatter"),
+ ("/cli test", "$0", "Auto-detect + run Tests ohne Analyse"),
+ ("/cli deps", "$0", "Dependency-Check (outdated + audit)"),
+ ("/cli git", "$0", "Git-Uebersicht (branches, stash, log)"),
+ ("/cli find", "$0", "Smart Code-Suche mit ripgrep"),
+ ("/cli project", "$0", "Projekt-Info (Stack, LOC, Configs)"),
+ ("/cli health", "$0", "System-Gesundheit (Disk, RAM, Ports)"),
+ ],
+ "Utilities": [
+ ("/check-state", "$0", "pwd, ls, git status — Orientierung"),
+ ("/validate-config", "$0", "Config pruefen + Backup"),
+ ("/review", "~$3", "Code Review der Aenderungen"),
+ ("/debug-loop", "~$3", "Max 5 Iterationen: Diagnose-Fix-Test"),
+ ("/explore-first", "~$0.50", "Parallele Codebase-Erkundung"),
+ ("/selfimprove", "$0", "CLAUDE.md + Companion Files verbessern"),
+ ("/recap", "$0", "Session zusammenfassen → SESSION_LOG.md"),
+ ("/quickwin", "$0", "3 kleine Tasks gegen Task-Paralysis"),
+ ("/checkpoint", "$0", "Git-Snapshot + Summary + Dopamin"),
+ ("/learn", "$0", "Nach Vibe-Coding: Wissen sichern"),
+ ("/focus", "$0", "EIN Ziel, Abschweifung bremsen"),
+ ("/zuendebringe", "$0", "Offene Faesser auflisten + abarbeiten"),
+ ],
+ "Multi-Agent": [
+ ("/crew", "~$0.60", "CrewAI mit MiniMax+Haiku Workern"),
+ ("/smol", "~$0.10", "Smolagents Code-Agent mit Tool-Use"),
+ ("/openhands", "~$0.30", "Autonomer Dev Agent via Docker"),
+ ],
+}
+
+
+# CSS is generated dynamically by theme.py (Catppuccin Mocha / Latte)
+
+_MAX_SESSION_ROWS = 5
+_MAX_TIMELINE_ROWS = 7
+
+
+def _open_path(path: str) -> None:
+ """Open a file or directory with xdg-open."""
+ resolved = Path(path).expanduser()
+ if not resolved.exists():
+ return # silently skip missing paths
+ subprocess.Popen(["xdg-open", str(resolved)], start_new_session=True)
+
+
+_DIAGRAMS_DIR = Path.home() / ".agent" / "diagrams"
+
+
+def _build_diagram_buttons(flowbox: "Gtk.FlowBox") -> None:
+ """Dynamically scan ~/.agent/diagrams/ for HTML files."""
+ # Clear existing children
+ for child in flowbox.get_children():
+ flowbox.remove(child)
+
+ if not _DIAGRAMS_DIR.exists():
+ label = Gtk.Label(label="Keine Diagramme gefunden")
+ label.get_style_context().add_class("session-meta")
+ flowbox.add(label)
+ flowbox.show_all()
+ return
+
+ # Recursively find all HTML files, sorted by mtime (newest first)
+ html_files = sorted(
+ _DIAGRAMS_DIR.rglob("*.html"),
+ key=lambda p: p.stat().st_mtime,
+ reverse=True,
+ )
+
+ if not html_files:
+ label = Gtk.Label(label="Keine Diagramme gefunden")
+ label.get_style_context().add_class("session-meta")
+ flowbox.add(label)
+ flowbox.show_all()
+ return
+
+ for html_path in html_files[:20]: # Cap at 20 newest
+ name = html_path.stem.replace("_", " ").replace("-", " ").title()
+ btn = Gtk.Button(label=name)
+ btn.get_style_context().add_class("shortcut-btn")
+ btn.get_style_context().add_class("shortcut-docs")
+ btn.set_tooltip_text(str(html_path))
+ btn.connect("clicked", lambda b, p=str(html_path): _open_path(p))
+ flowbox.add(btn)
+
+ flowbox.show_all()
+
+
+def _run_command(cmd: str) -> None:
+ """Run a shell command detached. Only call with trusted, hardcoded commands."""
+ subprocess.Popen(["bash", "-c", cmd], start_new_session=True,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+
+def _resume_session(session_id: str) -> None:
+ """Resume a Claude Code session in kitty terminal."""
+ subprocess.Popen(
+ ["kitty", "-e", "claude", "-r", session_id],
+ start_new_session=True,
+ )
+
+
+class ControlPanel(Gtk.Window):
+ def __init__(self):
+ super().__init__(title="Claude Code Control Panel")
+ self.set_default_size(580, 720)
+ self.set_resizable(True)
+
+ # Apply theme-aware CSS (auto-detects COSMIC light/dark)
+ css_provider = Gtk.CssProvider()
+ css_provider.load_from_data(build_css())
+ screen = Gdk.Screen.get_default()
+ Gtk.StyleContext.add_provider_for_screen(
+ screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+ )
+
+ # Watch for COSMIC theme changes and re-apply CSS automatically
+ setup_theme_watcher(css_provider)
+
+ # Load current settings
+ self.settings = read_settings()
+
+ # Main layout
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ self.add(vbox)
+
+ # Notebook (tabs)
+ self.notebook = Gtk.Notebook()
+ vbox.pack_start(self.notebook, True, True, 0)
+
+ # Build tabs
+ self._build_hub_tab()
+ self._build_settings_tab()
+ self._build_hooks_tab()
+ self._build_monitor_tab()
+ self._build_cost_tab()
+ self.notebook.append_page(build_logs_tab(), Gtk.Label(label="Logs"))
+ self.notebook.append_page(build_sessions_tab(), Gtk.Label(label="Sessions"))
+ self.notebook.append_page(build_processes_tab(), Gtk.Label(label="Prozesse"))
+ self.notebook.append_page(build_swarm_tab(), Gtk.Label(label="Swarm"))
+ self.notebook.append_page(build_shortcut_counter_tab(), Gtk.Label(label="Shortcuts"))
+ self.notebook.append_page(build_project_dashboard_tab(), Gtk.Label(label="Projekte"))
+ self.notebook.append_page(build_events_tab(), Gtk.Label(label="Events"))
+ self.notebook.append_page(build_brightness_tab(), Gtk.Label(label="Sonnenuhr"))
+
+ # Tab-Index → Refresh-Funktion Mapping
+ self._tab_refreshers = {
+ 0: self._refresh_hub,
+ 3: self._refresh_monitor,
+ 4: self._refresh_cost,
+ 5: refresh_logs,
+ 6: refresh_sessions,
+ 7: refresh_processes,
+ 8: refresh_swarm,
+ 9: refresh_shortcut_counter,
+ 10: refresh_project_dashboard,
+ 11: refresh_events,
+ 12: refresh_brightness,
+ }
+ self.notebook.connect("switch-page", self._on_tab_switched)
+
+ # Bottom status bar
+ status_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ status_bar.get_style_context().add_class("status-bar")
+ status_bar.set_margin_start(12)
+ status_bar.set_margin_end(12)
+ status_bar.set_margin_top(6)
+ status_bar.set_margin_bottom(6)
+
+ self.status_label = Gtk.Label(label="")
+ self.status_label.set_xalign(0)
+ status_bar.pack_start(self.status_label, True, True, 0)
+
+ save_btn = Gtk.Button(label="Speichern")
+ save_btn.get_style_context().add_class("suggested-action")
+ save_btn.connect("clicked", self.on_save)
+
+ reset_btn = Gtk.Button(label="Reset")
+ reset_btn.connect("clicked", self.on_reset)
+
+ status_bar.pack_end(save_btn, False, False, 0)
+ status_bar.pack_end(reset_btn, False, False, 0)
+
+ vbox.pack_start(status_bar, False, False, 0)
+
+ # Guarded timers — only refresh when tab is visible
+ GLib.timeout_add_seconds(30, self._refresh_hub)
+ GLib.timeout_add_seconds(15, self._start_monitor_timer)
+ GLib.timeout_add_seconds(10, self._guarded_refresh, 5, refresh_logs)
+ GLib.timeout_add_seconds(30, self._guarded_refresh, 6, refresh_sessions)
+ GLib.timeout_add_seconds(30, self._guarded_refresh, 7, refresh_processes)
+ GLib.timeout_add_seconds(30, self._refresh_cost)
+ GLib.timeout_add_seconds(30, self._guarded_refresh, 8, refresh_swarm)
+ GLib.timeout_add_seconds(30, self._guarded_refresh, 9, refresh_shortcut_counter)
+ GLib.timeout_add_seconds(30, self._guarded_refresh, 10, refresh_project_dashboard)
+ GLib.timeout_add_seconds(2, self._guarded_refresh, 11, refresh_events)
+ GLib.timeout_add_seconds(10, self._guarded_refresh, 12, refresh_brightness)
+
+ def _start_monitor_timer(self) -> bool:
+ """One-shot: starts the 30s monitor timer (offset from hub by 15s)."""
+ GLib.timeout_add_seconds(30, self._refresh_monitor)
+ self._refresh_monitor()
+ return False # don't repeat this one-shot
+
+ def _guarded_refresh(self, page_idx, func):
+ """Only refresh when tab is visible."""
+ if self.notebook.get_current_page() != page_idx:
+ return True
+ return func()
+
+ def _on_tab_switched(self, notebook, page, page_num):
+ """Refresh tab immediately on switch."""
+ refresher = self._tab_refreshers.get(page_num)
+ if refresher:
+ GLib.idle_add(refresher)
+
+ # -----------------------------------------------------------------------
+ # Tab 1: Hub — Dashboard, Sessions, Shortcuts
+ # -----------------------------------------------------------------------
+ def _build_hub_tab(self):
+ scrolled = Gtk.ScrolledWindow()
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ vbox.set_margin_top(16)
+ vbox.set_margin_bottom(16)
+ vbox.set_margin_start(16)
+ vbox.set_margin_end(16)
+
+ # --- Quick Stats Row ---
+ stats_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
+ stats_box.set_halign(Gtk.Align.CENTER)
+
+ self.hub_cost_label = Gtk.Label(label="...")
+ self.hub_cost_label.get_style_context().add_class("cost-value")
+ self.hub_cost_sublabel = Gtk.Label(label="Heutige Kosten")
+ self.hub_cost_sublabel.get_style_context().add_class("stat-label")
+
+ self.hub_calls_label = Gtk.Label(label="...")
+ self.hub_calls_label.get_style_context().add_class("stat-value")
+ self.hub_calls_sublabel = Gtk.Label(label="Tool Calls")
+ self.hub_calls_sublabel.get_style_context().add_class("stat-label")
+
+ self.hub_sessions_label = Gtk.Label(label="...")
+ self.hub_sessions_label.get_style_context().add_class("stat-value")
+ self.hub_sessions_sublabel = Gtk.Label(label="Aktive Sessions")
+ self.hub_sessions_sublabel.get_style_context().add_class("stat-label")
+
+ for val_label, sub_label in [
+ (self.hub_cost_label, self.hub_cost_sublabel),
+ (self.hub_calls_label, self.hub_calls_sublabel),
+ (self.hub_sessions_label, self.hub_sessions_sublabel),
+ ]:
+ col = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ col.set_halign(Gtk.Align.CENTER)
+ col.pack_start(val_label, False, False, 0)
+ col.pack_start(sub_label, False, False, 0)
+ stats_box.pack_start(col, True, True, 0)
+
+ vbox.pack_start(stats_box, False, False, 0)
+
+ # --- Provider Cost Breakdown (in Frame for visibility) ---
+ provider_frame = Gtk.Frame(label=" Kosten nach Provider ")
+ self.provider_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ self.provider_box.set_margin_top(8)
+ self.provider_box.set_margin_bottom(8)
+ self.provider_box.set_margin_start(10)
+ self.provider_box.set_margin_end(10)
+ provider_frame.add(self.provider_box)
+ vbox.pack_start(provider_frame, False, False, 4)
+
+ # Pre-create 4 provider rows (anthropic, minimax, codex, gemini)
+ self._provider_slots = {}
+ provider_colors = {
+ "anthropic": "#89b4fa", # blue
+ "minimax": "#a6e3a1", # green
+ "codex": "#fab387", # peach/orange
+ "gemini": "#f9e2af", # yellow
+ }
+ for provider_name in ["anthropic", "minimax", "codex", "gemini"]:
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ row.set_margin_start(8)
+
+ name_lbl = Gtk.Label(label=provider_name.capitalize(), xalign=0)
+ name_lbl.set_width_chars(12)
+ row.pack_start(name_lbl, False, False, 0)
+
+ # Color bar (using a Label with block characters)
+ bar_lbl = Gtk.Label(label="")
+ bar_lbl.set_xalign(0)
+ color = provider_colors.get(provider_name, "#cdd6f4")
+ bar_lbl.set_markup(f'')
+ row.pack_start(bar_lbl, True, True, 0)
+
+ cost_lbl = Gtk.Label(label="$0.00", xalign=1)
+ cost_lbl.set_width_chars(12)
+ cost_lbl.set_opacity(0.7)
+ row.pack_start(cost_lbl, False, False, 0)
+
+ self.provider_box.pack_start(row, False, False, 0)
+ row.set_no_show_all(True)
+
+ self._provider_slots[provider_name] = {
+ "row": row,
+ "name": name_lbl,
+ "bar": bar_lbl,
+ "cost": cost_lbl,
+ "color": color,
+ }
+
+ # --- Sidecar Watcher ---
+ watcher_label = Gtk.Label(label="Sidecar Watcher", xalign=0)
+ watcher_label.get_style_context().add_class("section-title")
+ vbox.pack_start(watcher_label, False, False, 4)
+
+ # Phase badge
+ self._phase_label = Gtk.Label(label="Sidecar offline")
+ self._phase_label.set_xalign(0)
+ self._phase_label.get_style_context().add_class("phase-badge")
+ self._phase_label.set_margin_start(10)
+ self._phase_label.set_margin_top(4)
+ vbox.pack_start(self._phase_label, False, False, 0)
+
+ watcher_frame = Gtk.Frame()
+ watcher_frame.get_style_context().add_class("section-frame")
+ self._watcher_flow = Gtk.FlowBox()
+ self._watcher_flow.set_max_children_per_line(4)
+ self._watcher_flow.set_min_children_per_line(3)
+ self._watcher_flow.set_selection_mode(Gtk.SelectionMode.NONE)
+ self._watcher_flow.set_homogeneous(True)
+ self._watcher_flow.set_margin_top(8)
+ self._watcher_flow.set_margin_bottom(8)
+ self._watcher_flow.set_margin_start(10)
+ self._watcher_flow.set_margin_end(10)
+ watcher_frame.add(self._watcher_flow)
+ vbox.pack_start(watcher_frame, False, False, 4)
+
+ # Set up Gio.FileMonitor on Sidecar V6 socket file
+ self._sidecar_monitor = None
+ try:
+ gio_file = Gio.File.new_for_path("/tmp/sidecar-v6.sock")
+ self._sidecar_monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None)
+ self._sidecar_monitor.connect("changed", self._on_sidecar_changed)
+ except Exception:
+ log.exception("sidecar file monitor setup")
+
+ # --- Diagrams (compact chip-style FlowBox) ---
+ diagrams_label = Gtk.Label(label="Diagramme", xalign=0)
+ diagrams_label.get_style_context().add_class("section-title")
+ vbox.pack_start(diagrams_label, False, False, 4)
+
+ diagrams_flow = Gtk.FlowBox()
+ diagrams_flow.set_max_children_per_line(5)
+ diagrams_flow.set_min_children_per_line(3)
+ diagrams_flow.set_selection_mode(Gtk.SelectionMode.NONE)
+ diagrams_flow.set_homogeneous(False)
+ diagrams_flow.set_row_spacing(4)
+ diagrams_flow.set_column_spacing(6)
+
+ _build_diagram_buttons(diagrams_flow)
+ self._diagrams_flow = diagrams_flow
+
+ vbox.pack_start(diagrams_flow, False, False, 0)
+
+ # --- Recent Sessions (pre-created fixed rows) ---
+ sessions_label = Gtk.Label(label="Letzte Sessions", xalign=0)
+ sessions_label.get_style_context().add_class("section-title")
+ vbox.pack_start(sessions_label, False, False, 4)
+
+ self.sessions_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ vbox.pack_start(self.sessions_box, False, False, 0)
+
+ self._session_slots = []
+ for _ in range(_MAX_SESSION_ROWS):
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ row.get_style_context().add_class("session-row")
+ row.set_margin_start(4)
+ row.set_margin_end(4)
+
+ info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ project_lbl = Gtk.Label(label="", xalign=0)
+ project_lbl.set_ellipsize(Pango.EllipsizeMode.END)
+ project_lbl.set_max_width_chars(20)
+ project_lbl.get_style_context().add_class("section-title")
+ info_box.pack_start(project_lbl, False, False, 0)
+
+ preview_lbl = Gtk.Label(label="", xalign=0)
+ preview_lbl.set_ellipsize(Pango.EllipsizeMode.END)
+ preview_lbl.set_max_width_chars(45)
+ preview_lbl.set_opacity(0.7)
+ info_box.pack_start(preview_lbl, False, False, 0)
+
+ row.pack_start(info_box, True, True, 0)
+
+ time_lbl = Gtk.Label(label="")
+ time_lbl.set_opacity(0.5)
+ row.pack_start(time_lbl, False, False, 0)
+
+ resume_btn = Gtk.Button(label="Resume")
+ resume_btn.set_size_request(70, -1)
+ row.pack_start(resume_btn, False, False, 0)
+
+ self.sessions_box.pack_start(row, False, False, 0)
+ row.set_no_show_all(True) # hidden by default until data arrives
+
+ self._session_slots.append({
+ "row": row,
+ "project": project_lbl,
+ "preview": preview_lbl,
+ "time": time_lbl,
+ "resume": resume_btn,
+ "_handler_id": None,
+ })
+
+ self._no_sessions_label = Gtk.Label(label="Keine Sessions gefunden")
+ self.sessions_box.pack_start(self._no_sessions_label, False, False, 0)
+
+ # --- Shortcuts Grid ---
+ shortcuts_label = Gtk.Label(label="Schnellzugriff", xalign=0)
+ shortcuts_label.get_style_context().add_class("section-title")
+ vbox.pack_start(shortcuts_label, False, False, 8)
+
+ shortcuts_flow = Gtk.FlowBox()
+ shortcuts_flow.set_max_children_per_line(5)
+ shortcuts_flow.set_min_children_per_line(4)
+ shortcuts_flow.set_selection_mode(Gtk.SelectionMode.NONE)
+ shortcuts_flow.set_homogeneous(True)
+ shortcuts_flow.set_row_spacing(8)
+ shortcuts_flow.set_column_spacing(8)
+
+ for sc in SHORTCUTS:
+ btn = Gtk.Button()
+ btn_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ btn_box.set_halign(Gtk.Align.CENTER)
+
+ # Try to load icon, fallback to label only
+ try:
+ icon = Gtk.Image.new_from_icon_name(sc["icon"], Gtk.IconSize.LARGE_TOOLBAR)
+ btn_box.pack_start(icon, False, False, 0)
+ except Exception:
+ log.exception("icon loading")
+
+ lbl = Gtk.Label(label=sc["label"])
+ lbl.set_ellipsize(Pango.EllipsizeMode.END)
+ lbl.set_max_width_chars(12)
+ btn_box.pack_start(lbl, False, False, 0)
+
+ btn.add(btn_box)
+ btn.get_style_context().add_class("shortcut-btn")
+ category = sc.get("category", "folder")
+ btn.get_style_context().add_class(f"shortcut-{category}")
+
+ # Check path existence for non-command shortcuts and dim if missing
+ if "command" not in sc:
+ resolved_path = Path(sc["path"]).expanduser()
+ if not resolved_path.exists():
+ btn.set_sensitive(False)
+ btn.set_opacity(0.4)
+ btn.set_tooltip_text(f"{sc['tooltip']} [Pfad nicht gefunden: {sc['path']}]")
+ else:
+ btn.set_tooltip_text(sc["tooltip"])
+ else:
+ btn.set_tooltip_text(sc["tooltip"])
+
+ # Connect click handler
+ if "command" in sc:
+ btn.connect("clicked", lambda _, cmd=sc["command"]: _run_command(cmd))
+ else:
+ btn.connect("clicked", lambda _, p=sc["path"]: _open_path(p))
+
+ shortcuts_flow.add(btn)
+
+ vbox.pack_start(shortcuts_flow, False, False, 0)
+
+ # --- Usage Timeline (pre-created fixed rows) ---
+ timeline_label = Gtk.Label(label="Usage (letzte 7 Tage)", xalign=0)
+ timeline_label.get_style_context().add_class("section-title")
+ vbox.pack_start(timeline_label, False, False, 8)
+
+ self.timeline_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ vbox.pack_start(self.timeline_box, False, False, 0)
+
+ self._timeline_slots = []
+ for _ in range(_MAX_TIMELINE_ROWS):
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+
+ date_lbl = Gtk.Label(label="")
+ date_lbl.set_width_chars(12)
+ date_lbl.set_xalign(0)
+ row.pack_start(date_lbl, False, False, 0)
+
+ bar_lbl = Gtk.Label(label="")
+ bar_lbl.get_style_context().add_class("monitor-value")
+ bar_lbl.set_xalign(0)
+ row.pack_start(bar_lbl, True, True, 0)
+
+ count_lbl = Gtk.Label(label="")
+ count_lbl.set_opacity(0.6)
+ count_lbl.set_width_chars(10)
+ count_lbl.set_xalign(1)
+ row.pack_start(count_lbl, False, False, 0)
+
+ self.timeline_box.pack_start(row, False, False, 0)
+ row.set_no_show_all(True)
+
+ self._timeline_slots.append({
+ "row": row,
+ "date": date_lbl,
+ "bar": bar_lbl,
+ "count": count_lbl,
+ })
+
+ # --- Skills Section (expandable by category) ---
+ skills_header = Gtk.Label(label="Skills", xalign=0)
+ skills_header.get_style_context().add_class("section-title")
+ vbox.pack_start(skills_header, False, False, 8)
+
+ for category, skills in SKILLS.items():
+ expander = Gtk.Expander(label=f" {category} ({len(skills)} Skills)")
+ expander.set_margin_start(4)
+
+ skill_grid = Gtk.Grid()
+ skill_grid.set_column_spacing(12)
+ skill_grid.set_row_spacing(4)
+ skill_grid.set_margin_top(6)
+ skill_grid.set_margin_bottom(6)
+ skill_grid.set_margin_start(16)
+
+ for i, (name, cost, desc) in enumerate(skills):
+ name_lbl = Gtk.Label(label=name, xalign=0)
+ name_lbl.get_style_context().add_class("section-title")
+ name_lbl.set_width_chars(18)
+ skill_grid.attach(name_lbl, 0, i, 1, 1)
+
+ cost_lbl = Gtk.Label(label=cost, xalign=0)
+ cost_lbl.set_opacity(0.6)
+ cost_lbl.set_width_chars(10)
+ skill_grid.attach(cost_lbl, 1, i, 1, 1)
+
+ desc_lbl = Gtk.Label(label=desc, xalign=0)
+ desc_lbl.set_ellipsize(Pango.EllipsizeMode.END)
+ desc_lbl.set_max_width_chars(40)
+ desc_lbl.set_hexpand(True)
+ skill_grid.attach(desc_lbl, 2, i, 1, 1)
+
+ expander.add(skill_grid)
+ vbox.pack_start(expander, False, False, 0)
+
+ # --- Archived Skills (from commands-archive/) ---
+ archive_dir = Path.home() / ".claude" / "commands-archive"
+ commands_dir = Path.home() / ".claude" / "commands"
+ archived = sorted(
+ f for f in archive_dir.glob("*.md")
+ if f.name != "INDEX.md"
+ ) if archive_dir.exists() else []
+
+ if archived:
+ archive_exp = Gtk.Expander(
+ label=f" Archiviert ({len(archived)} Skills)"
+ )
+ archive_exp.set_margin_start(4)
+
+ archive_grid = Gtk.Grid()
+ archive_grid.set_column_spacing(12)
+ archive_grid.set_row_spacing(4)
+ archive_grid.set_margin_top(6)
+ archive_grid.set_margin_bottom(6)
+ archive_grid.set_margin_start(16)
+
+ for i, skill_file in enumerate(archived):
+ name = skill_file.stem
+ is_active = (commands_dir / f"{name}.md").exists()
+
+ name_lbl = Gtk.Label(label=f"/{name}", xalign=0)
+ name_lbl.set_width_chars(18)
+ archive_grid.attach(name_lbl, 0, i, 1, 1)
+
+ btn = Gtk.Button(label="Aktiv" if is_active else "Aktivieren")
+ btn.set_sensitive(not is_active)
+ btn.connect(
+ "clicked", self._activate_archived_skill,
+ name, archive_dir, commands_dir,
+ )
+ archive_grid.attach(btn, 1, i, 1, 1)
+
+ archive_exp.add(archive_grid)
+ vbox.pack_start(archive_exp, False, False, 0)
+
+ # --- Missed Skills Today ---
+ missed_label = Gtk.Label(label="Verpasste Skills (heute)", xalign=0)
+ missed_label.get_style_context().add_class("section-title")
+ vbox.pack_start(missed_label, False, False, 8)
+
+ self.hub_missed_label = Gtk.Label(label="Lade...", xalign=0)
+ self.hub_missed_label.set_margin_start(8)
+ vbox.pack_start(self.hub_missed_label, False, False, 0)
+
+ scrolled.add(vbox)
+ self.notebook.append_page(scrolled, Gtk.Label(label="Hub"))
+
+ idle_once(self._refresh_hub)
+
+ def _activate_archived_skill(self, button, name, archive_dir, commands_dir):
+ """Copy a skill from commands-archive/ to commands/ to activate it."""
+ src = archive_dir / f"{name}.md"
+ dst = commands_dir / f"{name}.md"
+ if src.exists() and not dst.exists():
+ shutil.copy2(src, dst)
+ button.set_label("Aktiviert!")
+ button.set_sensitive(False)
+
+ def _refresh_hub(self) -> bool:
+ """Refresh hub tab data. Updates fixed labels — no widget rebuild."""
+ if self.notebook.get_current_page() != 0:
+ return True
+ try:
+ # Quick stats
+ cost_data = get_daily_cost()
+ if "error" not in cost_data:
+ self.hub_cost_label.set_text(format_cost(cost_data["cost_estimate_usd"]))
+ self.hub_calls_label.set_text(str(cost_data["total_calls"]))
+ else:
+ self.hub_cost_label.set_text("N/A")
+ self.hub_calls_label.set_text("N/A")
+
+ active = get_active_sessions()
+ self.hub_sessions_label.set_text(str(len(active)))
+
+ # Provider cost breakdown
+ provider_costs = get_provider_costs()
+ total_provider = sum(provider_costs.values()) or 0.001 # avoid div by zero
+
+ for provider_name, slot in self._provider_slots.items():
+ cost = provider_costs.get(provider_name, 0.0)
+ if cost > 0:
+ pct = cost / total_provider * 100
+ bar_len = max(1, int(pct / 100 * 30))
+ bar_text = "\u2588" * bar_len
+ color = slot["color"]
+ slot["bar"].set_markup(f'{bar_text}')
+ slot["cost"].set_text(f"${cost:.4f} ({pct:.0f}%)")
+ slot["row"].show_all()
+ else:
+ slot["row"].hide()
+
+ # Recent sessions — update fixed slots
+ sessions = get_recent_sessions(_MAX_SESSION_ROWS)
+ for i, slot in enumerate(self._session_slots):
+ if i < len(sessions):
+ s = sessions[i]
+ slot["project"].set_text(s["project"])
+ slot["preview"].set_text(s["preview"])
+ slot["time"].set_text(s["time_str"])
+
+ # Reconnect resume button to new session ID
+ if slot["_handler_id"] is not None:
+ slot["resume"].disconnect(slot["_handler_id"])
+ slot["_handler_id"] = slot["resume"].connect(
+ "clicked",
+ lambda _, sid=s["session_id"]: _resume_session(sid),
+ )
+ slot["row"].show_all()
+ else:
+ slot["row"].hide()
+
+ if sessions:
+ self._no_sessions_label.hide()
+ else:
+ self._no_sessions_label.show()
+
+ # Usage timeline — update fixed slots (token-based costs)
+ timeline = get_usage_timeline()
+ max_cost = max((t["cost_est"] for t in timeline), default=0.01) or 0.01
+
+ for i, slot in enumerate(self._timeline_slots):
+ if i < len(timeline):
+ t = timeline[i]
+ slot["date"].set_text(t["date"])
+ bar_len = max(1, int((t["cost_est"] / max_cost) * 30)) if t["cost_est"] > 0 else 0
+ slot["bar"].set_text("\u2588" * bar_len)
+ slot["count"].set_text(f"${t['cost_est']:.2f}")
+ slot["row"].show_all()
+ else:
+ slot["row"].hide()
+
+ # Missed skills in hub
+ missed = get_missed_skills_summary()
+ if missed:
+ lines = [f" {skill}: {count}x verpasst" for skill, count in missed[:5]]
+ self.hub_missed_label.set_text("\n".join(lines))
+ else:
+ self.hub_missed_label.set_text("Keine verpassten Skills heute")
+
+ # Sidecar watcher
+ sidecar = get_sidecar_status()
+ self._update_watcher(sidecar)
+
+ # Refresh diagrams (dynamic scan)
+ if hasattr(self, "_diagrams_flow"):
+ _build_diagram_buttons(self._diagrams_flow)
+ except Exception:
+ log.exception("refresh hub")
+ return True # keep 30s timer alive
+
+ def _update_watcher(self, data: dict) -> None:
+ """Update watcher dots dynamically from sidecar detectors dict."""
+ detectors = data.get("detectors", {})
+ phase = data.get("phase", "")
+ running = data.get("running", False)
+
+ # Update phase badge
+ if not running:
+ self._phase_label.set_text("Sidecar offline")
+ ctx = self._phase_label.get_style_context()
+ for cls in ("phase-badge-exploration", "phase-badge-implementation",
+ "phase-badge-testing", "phase-badge-debugging"):
+ ctx.remove_class(cls)
+ elif phase:
+ self._phase_label.set_text(f"\u25cf {phase.upper()}")
+ ctx = self._phase_label.get_style_context()
+ for cls in ("phase-badge-exploration", "phase-badge-implementation",
+ "phase-badge-testing", "phase-badge-debugging"):
+ ctx.remove_class(cls)
+ ctx.add_class(f"phase-badge-{phase}")
+ else:
+ self._phase_label.set_text("\u25cf IDLE")
+ ctx = self._phase_label.get_style_context()
+ for cls in ("phase-badge-exploration", "phase-badge-implementation",
+ "phase-badge-testing", "phase-badge-debugging"):
+ ctx.remove_class(cls)
+
+ # Clear existing flow children
+ for child in self._watcher_flow.get_children():
+ self._watcher_flow.remove(child)
+
+ # Sort: active first (by count desc), then inactive
+ sorted_dets = sorted(
+ detectors.items(),
+ key=lambda x: (not x[1].get("active", False), -x[1].get("count", 0)),
+ )
+
+ # Show top 12
+ shown = sorted_dets[:12]
+ for det_name, det in shown:
+ cell = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
+ dot = Gtk.Label(label="\u25cf")
+ active = det.get("active", False)
+ severity = det.get("severity", "info")
+ if active:
+ dot.get_style_context().add_class(f"watcher-dot-{severity}")
+ tooltip = f"{det_name}: {det.get('count', 0)}x"
+ last = det.get("last_seen", "")
+ if last:
+ tooltip += f" (zuletzt {last})"
+ else:
+ dot.get_style_context().add_class("watcher-dot-inactive")
+ tooltip = f"{det_name}: inaktiv"
+
+ cell.pack_start(dot, False, False, 0)
+
+ # Short name: take first 8 chars, replace underscores with hyphens
+ short = det_name.replace("_", "-")[:8].upper()
+ name_lbl = Gtk.Label(label=short, xalign=0)
+ name_lbl.get_style_context().add_class("watcher-name")
+ name_lbl.set_tooltip_text(tooltip)
+ cell.pack_start(name_lbl, False, False, 0)
+ dot.set_tooltip_text(tooltip)
+
+ cell.show_all()
+ self._watcher_flow.add(cell)
+
+ # Rest counter
+ rest = len(sorted_dets) - len(shown)
+ if rest > 0:
+ rest_label = Gtk.Label(label=f"+{rest} weitere")
+ rest_label.get_style_context().add_class("watcher-name")
+ rest_label.show_all()
+ self._watcher_flow.add(rest_label)
+
+ self._watcher_flow.show_all()
+
+ def _on_sidecar_changed(self, _monitor, file_obj, _other, event_type) -> None:
+ """Called when the Sidecar V6 socket file changes. Trigger watcher refresh."""
+ if event_type not in (
+ Gio.FileMonitorEvent.CHANGED,
+ Gio.FileMonitorEvent.CREATED,
+ Gio.FileMonitorEvent.CHANGES_DONE_HINT,
+ ):
+ return
+ name = file_obj.get_basename() if file_obj else ""
+ if name == "sidecar-v6.sock":
+ GLib.idle_add(self._refresh_watcher_once)
+
+ def _refresh_watcher_once(self) -> bool:
+ """One-shot idle callback: refresh watcher section only."""
+ try:
+ self._update_watcher(get_sidecar_status())
+ except Exception:
+ log.exception("idle watcher")
+ return False # one-shot
+
+ # -----------------------------------------------------------------------
+ # Tab 2: Settings
+ # -----------------------------------------------------------------------
+ def _build_settings_tab(self):
+ grid = Gtk.Grid()
+ grid.set_column_spacing(15)
+ grid.set_row_spacing(18)
+ grid.set_margin_top(20)
+ grid.set_margin_bottom(20)
+ grid.set_margin_start(20)
+ grid.set_margin_end(20)
+
+ row = 0
+
+ # --- Model ---
+ grid.attach(self._label("Model"), 0, row, 1, 1)
+ self.model_combo = Gtk.ComboBoxText()
+ models = ["opus", "sonnet", "haiku"]
+ for m in models:
+ self.model_combo.append_text(m)
+ current_model = self.settings.get("model", "opus")
+ if current_model in models:
+ self.model_combo.set_active(models.index(current_model))
+ else:
+ self.model_combo.set_active(0)
+ self.model_combo.set_hexpand(True)
+ grid.attach(self.model_combo, 1, row, 2, 1)
+
+ row += 1
+
+ # --- Autonomie ---
+ grid.attach(self._label("Autonomie"), 0, row, 1, 1)
+ self.autonomy_combo = Gtk.ComboBoxText()
+ for m in ["Balanced", "Sprint", "Conserve"]:
+ self.autonomy_combo.append_text(m)
+ self.autonomy_combo.set_active(0)
+ self.autonomy_combo.set_hexpand(True)
+ grid.attach(self.autonomy_combo, 1, row, 2, 1)
+
+ row += 1
+
+ # --- Max Subagents ---
+ grid.attach(self._label("Max Subagents"), 0, row, 1, 1)
+ self.subagents_adj = Gtk.Adjustment(value=8, lower=4, upper=16, step_increment=1)
+ self.subagents_scale = Gtk.Scale(
+ orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.subagents_adj
+ )
+ self.subagents_scale.set_digits(0)
+ self.subagents_scale.set_hexpand(True)
+ for v in [4, 8, 12, 16]:
+ self.subagents_scale.add_mark(v, Gtk.PositionType.BOTTOM, str(v))
+ grid.attach(self.subagents_scale, 1, row, 2, 1)
+
+ row += 1
+
+ # --- Tool Budget ---
+ grid.attach(self._label("Tool Budget"), 0, row, 1, 1)
+ current_budget = int(
+ self.settings.get("env", {}).get("SLASH_COMMAND_TOOL_CHAR_BUDGET", "10000")
+ )
+ self.budget_adj = Gtk.Adjustment(
+ value=current_budget, lower=1000, upper=50000, step_increment=1000
+ )
+ self.budget_scale = Gtk.Scale(
+ orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.budget_adj
+ )
+ self.budget_scale.set_digits(0)
+ self.budget_scale.set_hexpand(True)
+ for v in [1000, 10000, 25000, 50000]:
+ self.budget_scale.add_mark(v, Gtk.PositionType.BOTTOM, f"{v // 1000}k")
+ grid.attach(self.budget_scale, 1, row, 2, 1)
+
+ row += 1
+
+ # --- Agent Teams ---
+ grid.attach(self._label("Agent Teams"), 0, row, 1, 1)
+ self.teams_switch = Gtk.Switch()
+ teams_val = self.settings.get("env", {}).get(
+ "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "0"
+ )
+ self.teams_switch.set_active(teams_val == "1")
+ teams_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ teams_box.pack_start(self.teams_switch, False, False, 0)
+ teams_status = Gtk.Label(label=" An" if teams_val == "1" else " Aus")
+ teams_status.set_opacity(0.6)
+ teams_box.pack_start(teams_status, False, False, 5)
+ self.teams_switch.connect(
+ "notify::active",
+ lambda sw, _: teams_status.set_text(" An" if sw.get_active() else " Aus"),
+ )
+ grid.attach(teams_box, 1, row, 1, 1)
+
+ row += 1
+
+ # --- Voice Service ---
+ grid.attach(self._label("Voice Service"), 0, row, 1, 1)
+ self.voice_switch = Gtk.Switch()
+ # Check if voicemode is running
+ try:
+ result = subprocess.run(
+ ["systemctl", "--user", "is-active", "voicemode-edge-tts"],
+ capture_output=True,
+ text=True,
+ timeout=3,
+ )
+ self.voice_switch.set_active(result.stdout.strip() == "active")
+ except Exception:
+ log.exception("voice switch init")
+ self.voice_switch.set_active(False)
+ voice_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ voice_box.pack_start(self.voice_switch, False, False, 0)
+ self.voice_status = Gtk.Label(
+ label=" Running" if self.voice_switch.get_active() else " Stopped"
+ )
+ self.voice_status.set_opacity(0.6)
+ voice_box.pack_start(self.voice_status, False, False, 5)
+ grid.attach(voice_box, 1, row, 1, 1)
+
+ row += 1
+
+ # --- Separator ---
+ sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
+ grid.attach(sep, 0, row, 3, 1)
+ row += 1
+
+ # --- Section: MCP Servers ---
+ mcp_header = Gtk.Label(label="MCP Server", xalign=0)
+ mcp_header.get_style_context().add_class("section-title")
+ grid.attach(mcp_header, 0, row, 3, 1)
+ row += 1
+
+ mcp_servers = self.settings.get("mcpServers", {})
+ self.mcp_switches = {}
+ for server_name in ["opencode", "gemini", "voicemode"]:
+ grid.attach(self._label(f" {server_name}"), 0, row, 1, 1)
+ sw = Gtk.Switch()
+ sw.set_active(server_name in mcp_servers)
+ sw_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ sw_box.pack_start(sw, False, False, 0)
+ status_lbl = Gtk.Label(
+ label=" Aktiv" if server_name in mcp_servers else " Aus"
+ )
+ status_lbl.set_opacity(0.6)
+ sw_box.pack_start(status_lbl, False, False, 5)
+ sw.connect(
+ "notify::active",
+ lambda s, _, l=status_lbl: l.set_text(
+ " Aktiv" if s.get_active() else " Aus"
+ ),
+ )
+ grid.attach(sw_box, 1, row, 1, 1)
+ self.mcp_switches[server_name] = sw
+ row += 1
+
+ # --- Enable All Project MCP Servers ---
+ grid.attach(self._label("Projekt-MCPs"), 0, row, 1, 1)
+ self.project_mcp_switch = Gtk.Switch()
+ self.project_mcp_switch.set_active(
+ self.settings.get("enableAllProjectMcpServers", False)
+ )
+ proj_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ proj_box.pack_start(self.project_mcp_switch, False, False, 0)
+ proj_lbl = Gtk.Label(label=" Alle Projekt-MCPs erlauben")
+ proj_lbl.set_opacity(0.6)
+ proj_box.pack_start(proj_lbl, False, False, 5)
+ grid.attach(proj_box, 1, row, 2, 1)
+ row += 1
+
+ # --- Separator ---
+ sep2 = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
+ grid.attach(sep2, 0, row, 3, 1)
+ row += 1
+
+ # --- Section: Erweitert ---
+ adv_header = Gtk.Label(label="Erweitert", xalign=0)
+ adv_header.get_style_context().add_class("section-title")
+ grid.attach(adv_header, 0, row, 3, 1)
+ row += 1
+
+ # Tool Search
+ grid.attach(self._label("Tool Search"), 0, row, 1, 1)
+ self.tool_search_combo = Gtk.ComboBoxText()
+ for opt in ["auto:5", "auto:3", "auto:10", "disabled"]:
+ self.tool_search_combo.append_text(opt)
+ current_ts = self.settings.get("env", {}).get("ENABLE_TOOL_SEARCH", "auto:5")
+ ts_options = ["auto:5", "auto:3", "auto:10", "disabled"]
+ if current_ts in ts_options:
+ self.tool_search_combo.set_active(ts_options.index(current_ts))
+ else:
+ self.tool_search_combo.set_active(0)
+ self.tool_search_combo.set_hexpand(True)
+ grid.attach(self.tool_search_combo, 1, row, 2, 1)
+ row += 1
+
+ # Status Line toggle
+ grid.attach(self._label("Status Line"), 0, row, 1, 1)
+ self.statusline_switch = Gtk.Switch()
+ self.statusline_switch.set_active("statusLine" in self.settings)
+ sl_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ sl_box.pack_start(self.statusline_switch, False, False, 0)
+ sl_lbl = Gtk.Label(label=" Custom Statusline")
+ sl_lbl.set_opacity(0.6)
+ sl_box.pack_start(sl_lbl, False, False, 5)
+ grid.attach(sl_box, 1, row, 2, 1)
+
+ # Wrap grid in ScrolledWindow (many settings now)
+ settings_scroll = Gtk.ScrolledWindow()
+ settings_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ settings_scroll.add(grid)
+ self.notebook.append_page(settings_scroll, Gtk.Label(label="Settings"))
+
+ # -----------------------------------------------------------------------
+ # Tab 3: Hooks
+ # -----------------------------------------------------------------------
+ def _build_hooks_tab(self):
+ scrolled = Gtk.ScrolledWindow()
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ vbox.set_margin_top(12)
+ vbox.set_margin_bottom(12)
+ vbox.set_margin_start(12)
+ vbox.set_margin_end(12)
+
+ self.hook_widgets = []
+ hooks = read_hook_list()
+
+ for hook_info in hooks:
+ frame = Gtk.Frame(
+ label=f" {hook_info['event']} {hook_info['short_name']} "
+ )
+ frame_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ frame_box.set_margin_top(6)
+ frame_box.set_margin_bottom(6)
+ frame_box.set_margin_start(8)
+ frame_box.set_margin_end(8)
+
+ # Matcher
+ if hook_info["matcher"]:
+ matcher_lbl = Gtk.Label(label=f"[{hook_info['matcher']}]")
+ matcher_lbl.set_opacity(0.5)
+ frame_box.pack_start(matcher_lbl, False, False, 0)
+
+ # Timeout
+ frame_box.pack_start(Gtk.Label(label="Timeout:"), False, False, 0)
+ timeout_adj = Gtk.Adjustment(
+ value=hook_info["timeout"], lower=3, upper=120, step_increment=1
+ )
+ timeout_spin = Gtk.SpinButton(
+ adjustment=timeout_adj, climb_rate=1, digits=0
+ )
+ timeout_spin.set_width_chars(4)
+ frame_box.pack_start(timeout_spin, False, False, 0)
+ frame_box.pack_start(Gtk.Label(label="s"), False, False, 0)
+
+ # Async
+ frame_box.pack_start(Gtk.Label(label="Async:"), False, False, 5)
+ async_switch = Gtk.Switch()
+ async_switch.set_active(hook_info["async_"])
+ frame_box.pack_start(async_switch, False, False, 0)
+
+ frame.add(frame_box)
+ vbox.pack_start(frame, False, False, 0)
+
+ self.hook_widgets.append({
+ "info": hook_info,
+ "timeout_adj": timeout_adj,
+ "async_switch": async_switch,
+ })
+
+ # --- Coaching Rate Limit ---
+ sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
+ vbox.pack_start(sep, False, False, 5)
+
+ coach_frame = Gtk.Frame(label=" Coaching Rate Limit ")
+ coach_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ coach_box.set_margin_top(8)
+ coach_box.set_margin_bottom(8)
+ coach_box.set_margin_start(10)
+ coach_box.set_margin_end(10)
+
+ rate = read_coaching_rate_limit()
+ self.coaching_adj = Gtk.Adjustment(
+ value=rate, lower=60, upper=600, step_increment=30
+ )
+ self.coaching_scale = Gtk.Scale(
+ orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.coaching_adj
+ )
+ self.coaching_scale.set_digits(0)
+ self.coaching_scale.set_hexpand(True)
+ for v in [60, 180, 300, 600]:
+ self.coaching_scale.add_mark(v, Gtk.PositionType.BOTTOM, f"{v}s")
+
+ coach_box.pack_start(Gtk.Label(label="Rate:"), False, False, 0)
+ coach_box.pack_start(self.coaching_scale, True, True, 0)
+
+ coach_frame.add(coach_box)
+ vbox.pack_start(coach_frame, False, False, 0)
+
+ scrolled.add(vbox)
+ self.notebook.append_page(scrolled, Gtk.Label(label="Hooks"))
+
+ # -----------------------------------------------------------------------
+ # Tab 4: Monitor
+ # -----------------------------------------------------------------------
+ def _build_monitor_tab(self):
+ scrolled = Gtk.ScrolledWindow()
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
+ vbox.set_margin_top(16)
+ vbox.set_margin_bottom(16)
+ vbox.set_margin_start(16)
+ vbox.set_margin_end(16)
+
+ # Phase badge (reuses same CSS as Hub tab)
+ self._monitor_phase_label = Gtk.Label(label="Sidecar offline")
+ self._monitor_phase_label.set_xalign(0)
+ self._monitor_phase_label.get_style_context().add_class("phase-badge")
+ vbox.pack_start(self._monitor_phase_label, False, False, 0)
+
+ # --- Cost + Sessions row ---
+ top_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ top_row.set_homogeneous(True)
+
+ # Cost card
+ cost_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ cost_card.get_style_context().add_class("signal-card")
+ cost_card.set_margin_top(6)
+ cost_card.set_margin_bottom(6)
+ self._mon_cost_value = Gtk.Label(label="$0.00")
+ self._mon_cost_value.set_xalign(0)
+ self._mon_cost_value.get_style_context().add_class("cost-value")
+ cost_card.pack_start(self._mon_cost_value, False, False, 0)
+ self._mon_cost_sub = Gtk.Label(label="0 Calls, 0 Tools")
+ self._mon_cost_sub.set_xalign(0)
+ self._mon_cost_sub.get_style_context().add_class("stat-sublabel")
+ cost_card.pack_start(self._mon_cost_sub, False, False, 0)
+
+ # Yesterday label
+ self._mon_cost_yesterday = Gtk.Label(label="")
+ self._mon_cost_yesterday.set_xalign(0)
+ self._mon_cost_yesterday.get_style_context().add_class("stat-sublabel")
+ cost_card.pack_start(self._mon_cost_yesterday, False, False, 0)
+
+ # Sparkline (7-day cost trend)
+ self._sparkline_data = []
+ self._sparkline_area = Gtk.DrawingArea()
+ self._sparkline_area.set_size_request(120, 24)
+ self._sparkline_area.connect("draw", self._draw_sparkline)
+ cost_card.pack_start(self._sparkline_area, False, False, 2)
+
+ # Provider segment bar
+ self._mon_provider_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
+ cost_card.pack_start(self._mon_provider_box, False, False, 0)
+
+ top_row.pack_start(cost_card, True, True, 0)
+
+ # Sessions card
+ sess_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ sess_card.get_style_context().add_class("signal-card")
+ sess_card.set_margin_top(6)
+ sess_card.set_margin_bottom(6)
+ self._mon_sess_value = Gtk.Label(label="0 Sessions")
+ self._mon_sess_value.set_xalign(0)
+ self._mon_sess_value.get_style_context().add_class("mon-stat-value")
+ sess_card.pack_start(self._mon_sess_value, False, False, 0)
+ self._mon_sess_sub = Gtk.Label(label="")
+ self._mon_sess_sub.set_xalign(0)
+ self._mon_sess_sub.get_style_context().add_class("stat-sublabel")
+ self._mon_sess_sub.set_line_wrap(True)
+ sess_card.pack_start(self._mon_sess_sub, False, False, 0)
+ top_row.pack_start(sess_card, True, True, 0)
+
+ vbox.pack_start(top_row, False, False, 0)
+
+ # --- Mechanisms card ---
+ mech_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ mech_card.get_style_context().add_class("signal-card")
+ mech_label = Gtk.Label(label="Mechanisms", xalign=0)
+ mech_label.get_style_context().add_class("stat-sublabel")
+ mech_card.pack_start(mech_label, False, False, 0)
+ self._mon_mech_bar = Gtk.LevelBar()
+ self._mon_mech_bar.set_min_value(0)
+ self._mon_mech_bar.set_max_value(36)
+ self._mon_mech_bar.set_value(0)
+ self._mon_mech_bar.set_size_request(-1, 16)
+ mech_card.pack_start(self._mon_mech_bar, False, False, 0)
+ self._mon_mech_sub = Gtk.Label(label="0/36 aktiv")
+ self._mon_mech_sub.set_xalign(0)
+ self._mon_mech_sub.get_style_context().add_class("stat-sublabel")
+ mech_card.pack_start(self._mon_mech_sub, False, False, 0)
+
+ # Severity buckets
+ self._mon_sev_labels = {}
+ self._mon_sev_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
+ for sev in ("critical", "warning", "info"):
+ hb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=3)
+ dot = Gtk.Label(label="●")
+ dot.get_style_context().add_class(f"watcher-dot-{sev}")
+ hb.pack_start(dot, False, False, 0)
+ lbl = Gtk.Label(label=f"{sev.title()}: 0")
+ lbl.get_style_context().add_class("stat-sublabel")
+ hb.pack_start(lbl, False, False, 0)
+ self._mon_sev_labels[sev] = lbl
+ self._mon_sev_box.pack_start(hb, False, False, 0)
+ mech_card.pack_start(self._mon_sev_box, False, False, 0)
+
+ vbox.pack_start(mech_card, False, False, 0)
+
+ # --- Tools + Skills row ---
+ bottom_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
+ bottom_row.set_homogeneous(True)
+
+ # Top Tools card (ProgressBar slots)
+ tools_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ tools_card.get_style_context().add_class("signal-card")
+ tools_title = Gtk.Label(label="Top Tools", xalign=0)
+ tools_title.get_style_context().add_class("stat-sublabel")
+ tools_card.pack_start(tools_title, False, False, 0)
+ self._mon_tool_slots = []
+ for _ in range(5):
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ name_lbl = Gtk.Label(label="")
+ name_lbl.set_xalign(0)
+ name_lbl.set_width_chars(8)
+ name_lbl.get_style_context().add_class("watcher-name")
+ row.pack_start(name_lbl, False, False, 0)
+ bar = Gtk.ProgressBar()
+ bar.set_fraction(0)
+ bar.get_style_context().add_class("mon-tool-bar")
+ row.pack_start(bar, True, True, 0)
+ count_lbl = Gtk.Label(label="")
+ count_lbl.set_xalign(1)
+ count_lbl.set_width_chars(5)
+ count_lbl.get_style_context().add_class("stat-sublabel")
+ row.pack_start(count_lbl, False, False, 0)
+ tools_card.pack_start(row, False, False, 0)
+ row.set_no_show_all(True)
+ self._mon_tool_slots.append((row, name_lbl, bar, count_lbl))
+ tools_card.pack_start(Gtk.Label(), False, False, 0) # empty fallback hidden by default
+ bottom_row.pack_start(tools_card, True, True, 0)
+
+ # Missed Skills card
+ skills_card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ skills_card.get_style_context().add_class("signal-card")
+ skills_title = Gtk.Label(label="Verpasste Skills", xalign=0)
+ skills_title.get_style_context().add_class("stat-sublabel")
+ skills_card.pack_start(skills_title, False, False, 0)
+ self._mon_skills_label = Gtk.Label(label="Keine")
+ self._mon_skills_label.set_xalign(0)
+ self._mon_skills_label.get_style_context().add_class("monitor-value")
+ skills_card.pack_start(self._mon_skills_label, False, False, 0)
+ bottom_row.pack_start(skills_card, True, True, 0)
+
+ # Detail expander (collapsed by default)
+ self._mon_expander = Gtk.Expander()
+ self._mon_expander.set_label("Tools & Skills")
+ self._mon_expander.get_style_context().add_class("mon-expander")
+ self._mon_expander.set_expanded(False)
+ self._mon_expander.add(bottom_row)
+ vbox.pack_start(self._mon_expander, False, False, 0)
+
+ scrolled.add(vbox)
+ self.notebook.append_page(scrolled, Gtk.Label(label="Monitor"))
+
+ idle_once(self._refresh_monitor)
+
+ def _refresh_monitor(self) -> bool:
+ """Refresh monitor tab with signal cards."""
+ if self.notebook.get_current_page() != 3:
+ return True
+ try:
+ # Cost
+ cost_data = get_daily_cost()
+ if "error" not in cost_data:
+ self._mon_cost_value.set_text(format_cost(cost_data["cost_estimate_usd"]))
+ self._mon_cost_sub.set_text(
+ f"{cost_data['total_calls']} Calls, {cost_data['unique_tools']} Tools"
+ )
+ else:
+ self._mon_cost_value.set_text("N/A")
+ self._mon_cost_sub.set_text(cost_data.get("error", ""))
+
+ # Sparkline + yesterday
+ timeline = get_usage_timeline()
+ self._sparkline_data = timeline
+ self._sparkline_area.queue_draw()
+ if len(timeline) >= 2:
+ yesterday_cost = timeline[1].get("cost_est", 0)
+ self._mon_cost_yesterday.set_text(f"gestern: {format_cost(yesterday_cost)}")
+ else:
+ self._mon_cost_yesterday.set_text("")
+
+ # Provider segments
+ for child in self._mon_provider_box.get_children():
+ self._mon_provider_box.remove(child)
+ p = get_palette()
+ prov_costs = get_provider_costs()
+ if prov_costs:
+ for prov, cost_val in sorted(prov_costs.items(), key=lambda x: -x[1])[:4]:
+ color_key = _PROVIDER_COLORS.get(prov, "dim")
+ color = p.get(color_key, p["dim"])
+ initial = prov[0].upper()
+ lbl = Gtk.Label()
+ lbl.set_markup(
+ f''
+ f'{initial} {format_cost(cost_val)}'
+ )
+ self._mon_provider_box.pack_start(lbl, False, False, 0)
+ self._mon_provider_box.show_all()
+
+ # Sessions
+ sessions = get_active_sessions()
+ if sessions:
+ self._mon_sess_value.set_text(f"{len(sessions)} Sessions")
+ names = [s["project"] for s in sessions[:4]]
+ self._mon_sess_sub.set_text(", ".join(names))
+ else:
+ self._mon_sess_value.set_text("0 Sessions")
+ self._mon_sess_sub.set_text("Keine aktiven Sessions")
+
+ # Mechanisms (Sidecar)
+ sidecar = get_sidecar_status()
+
+ # Phase badge (same logic as Hub)
+ running = sidecar.get("running", False)
+ phase = sidecar.get("phase", "")
+ if not running:
+ self._monitor_phase_label.set_text("Sidecar offline")
+ ctx = self._monitor_phase_label.get_style_context()
+ for cls in ("phase-badge-exploration", "phase-badge-implementation",
+ "phase-badge-testing", "phase-badge-debugging"):
+ ctx.remove_class(cls)
+ elif phase:
+ self._monitor_phase_label.set_text(f"● {phase.upper()}")
+ ctx = self._monitor_phase_label.get_style_context()
+ for cls in ("phase-badge-exploration", "phase-badge-implementation",
+ "phase-badge-testing", "phase-badge-debugging"):
+ ctx.remove_class(cls)
+ ctx.add_class(f"phase-badge-{phase}")
+ else:
+ self._monitor_phase_label.set_text("● IDLE")
+ ctx = self._monitor_phase_label.get_style_context()
+ for cls in ("phase-badge-exploration", "phase-badge-implementation",
+ "phase-badge-testing", "phase-badge-debugging"):
+ ctx.remove_class(cls)
+
+ if not running:
+ self._mon_mech_bar.set_value(0)
+ self._mon_mech_sub.set_text("Sidecar offline")
+ else:
+ total = sidecar.get("mechanisms_total", 0) or 36
+ active_count = sidecar.get("mechanisms_active", 0)
+ self._mon_mech_bar.set_max_value(max(total, 1))
+ self._mon_mech_bar.set_value(active_count)
+ active_names = sidecar.get("active_findings", [])
+ if active_names:
+ self._mon_mech_sub.set_text(
+ f"{active_count}/{total} aktiv: {', '.join(active_names[:5])}"
+ )
+ else:
+ self._mon_mech_sub.set_text(f"{active_count}/{total} aktiv")
+
+ # Severity buckets
+ counts = {"critical": 0, "warning": 0, "info": 0}
+ if running:
+ for det_info in sidecar.get("detectors", {}).values():
+ if det_info.get("active"):
+ sev = det_info.get("severity", "info")
+ counts[sev] = counts.get(sev, 0) + 1
+ for sev, cnt in counts.items():
+ if sev in self._mon_sev_labels:
+ self._mon_sev_labels[sev].set_text(f"{sev.title()}: {cnt}")
+
+ # Top Tools (ProgressBar slots)
+ tools = get_top_tools(5)
+ max_count = tools[0][1] if tools else 1
+ for i, (row, name_lbl, bar, count_lbl) in enumerate(self._mon_tool_slots):
+ if i < len(tools):
+ name, count = tools[i]
+ name_lbl.set_text(name)
+ bar.set_fraction(count / max(max_count, 1))
+ count_lbl.set_text(f"{count}x")
+ row.set_no_show_all(False)
+ row.show_all()
+ else:
+ row.hide()
+ row.set_no_show_all(True)
+
+ # Missed Skills
+ missed = get_missed_skills_summary()
+ if missed:
+ lines = [f"{skill}: {count}x" for skill, count in missed]
+ self._mon_skills_label.set_text("\n".join(lines))
+ else:
+ self._mon_skills_label.set_text("Keine verpassten Skills heute")
+
+ # Update expander header
+ top_tool = tools[0] if tools else None
+ missed_count = len(missed) if missed else 0
+ header_parts = []
+ if top_tool:
+ header_parts.append(f"Tools: {top_tool[0]} {top_tool[1]}x")
+ if missed_count:
+ header_parts.append(f"Skills: {missed_count} verpasst")
+ self._mon_expander.set_label(" | ".join(header_parts) if header_parts else "Tools & Skills")
+ except Exception:
+ log.exception("monitor refresh")
+ return True # keep 30s timer alive
+
+ def _draw_sparkline(self, widget, cr):
+ """Draw 7-day cost sparkline using Cairo."""
+ p = get_palette()
+ width = widget.get_allocated_width()
+ height = widget.get_allocated_height()
+ data = self._sparkline_data
+
+ if len(data) < 2:
+ cr.set_source_rgba(*_hex_rgb(p["dim"]), 0.3)
+ cr.select_font_face("monospace", 0, 0)
+ cr.set_font_size(9)
+ cr.move_to(4, height / 2 + 3)
+ cr.show_text("—")
+ return False
+
+ costs = [d.get("cost_est", 0) for d in reversed(data)] # oldest first
+ max_c = max(costs) or 0.01
+ n = len(costs)
+ step = width / max(n - 1, 1)
+
+ # Build points
+ points = []
+ for i, c in enumerate(costs):
+ x = i * step
+ y = height - 2 - (c / max_c) * (height - 4)
+ points.append((x, y))
+
+ # Fill area under the line
+ r, g, b = _hex_rgb(p["accent"])
+ cr.move_to(points[0][0], height)
+ for x, y in points:
+ cr.line_to(x, y)
+ cr.line_to(points[-1][0], height)
+ cr.close_path()
+ cr.set_source_rgba(r, g, b, 0.15)
+ cr.fill()
+
+ # Polyline
+ cr.set_source_rgba(r, g, b, 0.8)
+ cr.set_line_width(1.5)
+ cr.move_to(*points[0])
+ for x, y in points[1:]:
+ cr.line_to(x, y)
+ cr.stroke()
+
+ return False
+
+ # -----------------------------------------------------------------------
+ # Tab 5: Cost — Provider breakdown + usage timeline
+ # -----------------------------------------------------------------------
+ def _build_cost_tab(self):
+ scrolled = Gtk.ScrolledWindow()
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ vbox.set_margin_top(16)
+ vbox.set_margin_bottom(16)
+ vbox.set_margin_start(16)
+ vbox.set_margin_end(16)
+
+ # --- Total Daily Cost (big) ---
+ self.cost_total_label = Gtk.Label(label="...")
+ self.cost_total_label.get_style_context().add_class("cost-value")
+ total_sub = Gtk.Label(label="Heutige Gesamtkosten")
+ total_sub.get_style_context().add_class("stat-label")
+ total_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ total_box.set_halign(Gtk.Align.CENTER)
+ total_box.pack_start(self.cost_total_label, False, False, 0)
+ total_box.pack_start(total_sub, False, False, 0)
+ vbox.pack_start(total_box, False, False, 8)
+
+ # --- Provider Breakdown ---
+ provider_frame = Gtk.Frame(label=" Provider-Breakdown ")
+ provider_inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ provider_inner.set_margin_top(10)
+ provider_inner.set_margin_bottom(10)
+ provider_inner.set_margin_start(12)
+ provider_inner.set_margin_end(12)
+
+ self._cost_provider_slots = {}
+ provider_meta = [
+ ("opus", "#89b4fa", "Claude Opus 4.6 ($5/$6.25/$0.50/$25)"),
+ ("sonnet", "#b4befe", "Claude Sonnet 4.6 ($3/$3.75/$0.30/$15)"),
+ ("haiku", "#94e2d5", "Claude Haiku 4.5 ($1/$1.25/$0.10/$5)"),
+ ("minimax", "#a6e3a1", "MiniMax M2.5 (~$0.05/1M)"),
+ ("codex", "#fab387", "Codex/OpenCode (~$0.10/1M)"),
+ ("gemini", "#f9e2af", "Gemini Flash (~$0.10/1M)"),
+ ]
+ for name, color, desc in provider_meta:
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+
+ name_lbl = Gtk.Label(xalign=0)
+ name_lbl.set_markup(f'{name.capitalize()}')
+ name_lbl.set_width_chars(12)
+ row.pack_start(name_lbl, False, False, 0)
+
+ bar_lbl = Gtk.Label(label="")
+ bar_lbl.set_xalign(0)
+ row.pack_start(bar_lbl, True, True, 0)
+
+ cost_lbl = Gtk.Label(label="—", xalign=1)
+ cost_lbl.set_width_chars(16)
+ row.pack_start(cost_lbl, False, False, 0)
+
+ desc_lbl = Gtk.Label(label=desc)
+ desc_lbl.set_opacity(0.5)
+ desc_lbl.set_xalign(0)
+
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
+ box.pack_start(row, False, False, 0)
+ box.pack_start(desc_lbl, False, False, 0)
+
+ provider_inner.pack_start(box, False, False, 0)
+ self._cost_provider_slots[name] = {
+ "bar": bar_lbl,
+ "cost": cost_lbl,
+ "color": color,
+ }
+
+ provider_frame.add(provider_inner)
+ vbox.pack_start(provider_frame, False, False, 0)
+
+ # --- Calls + Tools Summary ---
+ stats_frame = Gtk.Frame(label=" Heutige Nutzung ")
+ stats_inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
+ stats_inner.set_halign(Gtk.Align.CENTER)
+ stats_inner.set_margin_top(10)
+ stats_inner.set_margin_bottom(10)
+
+ self.cost_calls_label = Gtk.Label(label="...")
+ self.cost_calls_label.get_style_context().add_class("stat-value")
+ calls_sub = Gtk.Label(label="Tool Calls")
+ calls_sub.get_style_context().add_class("stat-label")
+ col1 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ col1.pack_start(self.cost_calls_label, False, False, 0)
+ col1.pack_start(calls_sub, False, False, 0)
+ stats_inner.pack_start(col1, True, True, 0)
+
+ self.cost_tools_label = Gtk.Label(label="...")
+ self.cost_tools_label.get_style_context().add_class("stat-value")
+ tools_sub = Gtk.Label(label="Unique Tools")
+ tools_sub.get_style_context().add_class("stat-label")
+ col2 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ col2.pack_start(self.cost_tools_label, False, False, 0)
+ col2.pack_start(tools_sub, False, False, 0)
+ stats_inner.pack_start(col2, True, True, 0)
+
+ stats_frame.add(stats_inner)
+ vbox.pack_start(stats_frame, False, False, 0)
+
+ # --- Skill Usage ---
+ skill_frame = Gtk.Frame(label=" Skill-Nutzung ")
+ self.skill_usage_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ self.skill_usage_box.set_margin_top(8)
+ self.skill_usage_box.set_margin_bottom(8)
+ self.skill_usage_box.set_margin_start(12)
+ self.skill_usage_box.set_margin_end(12)
+
+ _MAX_SKILL_ROWS = 8
+ self._skill_slots = []
+ for _ in range(_MAX_SKILL_ROWS):
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ name_lbl = Gtk.Label(label="", xalign=0)
+ name_lbl.set_width_chars(18)
+ row.pack_start(name_lbl, False, False, 0)
+
+ today_lbl = Gtk.Label(label="", xalign=1)
+ today_lbl.set_width_chars(12)
+ row.pack_start(today_lbl, True, True, 0)
+
+ week_lbl = Gtk.Label(label="", xalign=1)
+ week_lbl.set_width_chars(10)
+ week_lbl.set_opacity(0.6)
+ row.pack_start(week_lbl, False, False, 0)
+
+ self.skill_usage_box.pack_start(row, False, False, 0)
+ row.set_no_show_all(True)
+ self._skill_slots.append({
+ "row": row, "name": name_lbl, "today": today_lbl, "week": week_lbl,
+ })
+
+ self.skill_no_data_label = Gtk.Label(label="Keine Skills genutzt")
+ self.skill_no_data_label.set_opacity(0.4)
+ self.skill_usage_box.pack_start(self.skill_no_data_label, False, False, 4)
+
+ skill_frame.add(self.skill_usage_box)
+ vbox.pack_start(skill_frame, False, False, 0)
+
+ # --- 7-Day Timeline ---
+ timeline_frame = Gtk.Frame(label=" Letzte 7 Tage ")
+ self.cost_timeline_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ self.cost_timeline_box.set_margin_top(8)
+ self.cost_timeline_box.set_margin_bottom(8)
+ self.cost_timeline_box.set_margin_start(10)
+ self.cost_timeline_box.set_margin_end(10)
+
+ self._cost_timeline_slots = []
+ for _ in range(7):
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ date_lbl = Gtk.Label(label="", xalign=0)
+ date_lbl.set_width_chars(12)
+ row.pack_start(date_lbl, False, False, 0)
+
+ bar_lbl = Gtk.Label(label="")
+ bar_lbl.get_style_context().add_class("monitor-value")
+ bar_lbl.set_xalign(0)
+ row.pack_start(bar_lbl, True, True, 0)
+
+ info_lbl = Gtk.Label(label="", xalign=1)
+ info_lbl.set_width_chars(18)
+ info_lbl.set_opacity(0.6)
+ row.pack_start(info_lbl, False, False, 0)
+
+ self.cost_timeline_box.pack_start(row, False, False, 0)
+ row.set_no_show_all(True)
+ self._cost_timeline_slots.append({
+ "row": row, "date": date_lbl, "bar": bar_lbl, "info": info_lbl,
+ })
+
+ timeline_frame.add(self.cost_timeline_box)
+ vbox.pack_start(timeline_frame, False, False, 0)
+
+ scrolled.add(vbox)
+ self.notebook.append_page(scrolled, Gtk.Label(label="Cost"))
+
+ idle_once(self._refresh_cost)
+
+ def _refresh_cost(self) -> bool:
+ """Refresh cost tab data with real token-based costs."""
+ if self.notebook.get_current_page() != 4:
+ return True
+ try:
+ cost_data = get_daily_cost()
+ if "error" not in cost_data:
+ self.cost_total_label.set_text(format_cost(cost_data["cost_estimate_usd"]))
+ self.cost_calls_label.set_text(str(cost_data["total_calls"]))
+ self.cost_tools_label.set_text(str(cost_data.get("unique_tools", "?")))
+ else:
+ self.cost_total_label.set_text("N/A")
+
+ # Provider breakdown with token details
+ provider_costs = get_provider_costs()
+ total_p = sum(provider_costs.values()) or 0.001
+ anthropic_data = get_anthropic_session_cost()
+ anthropic_models = anthropic_data.get("models", {})
+ # Providers with estimated costs get "~" prefix
+ estimated_providers = {"codex", "gemini"}
+ model_tiers = {"opus", "sonnet", "haiku"}
+
+ for name, slot in self._cost_provider_slots.items():
+ cost = provider_costs.get(name, 0.0)
+ if cost > 0:
+ pct = cost / total_p * 100
+ bar_len = max(1, int(pct / 100 * 25))
+ slot["bar"].set_markup(
+ f'{"\u2588" * bar_len}'
+ )
+ prefix = "~" if name in estimated_providers else ""
+ if name in model_tiers and name in anthropic_models:
+ md = anthropic_models[name]
+ inp_k = md.get("input_tokens", 0) / 1000
+ cache_k = md.get("cache_read_tokens", 0) / 1000
+ out_k = md.get("output_tokens", 0) / 1000
+ slot["cost"].set_text(
+ f"${cost:.2f} ({inp_k:.0f}K in / {cache_k:.0f}K cache / {out_k:.0f}K out)"
+ )
+ else:
+ slot["cost"].set_text(f"{prefix}${cost:.4f} ({pct:.0f}%)")
+ else:
+ slot["bar"].set_text("")
+ slot["cost"].set_text("—")
+
+ # Skill usage — today + weekly totals
+ today_str = __import__("datetime").datetime.now(
+ __import__("datetime").timezone.utc
+ ).strftime("%Y-%m-%d")
+ skills_today = get_skill_usage(days=1).get(today_str, {})
+ skills_week = get_skill_usage(days=7)
+
+ # Aggregate weekly totals per skill
+ week_totals: dict[str, int] = {}
+ for day_skills in skills_week.values():
+ for sk, cnt in day_skills.items():
+ week_totals[sk] = week_totals.get(sk, 0) + cnt
+
+ # Sort by today's count (desc), then by weekly count
+ all_skills = sorted(
+ set(skills_today) | set(week_totals),
+ key=lambda s: (skills_today.get(s, 0), week_totals.get(s, 0)),
+ reverse=True,
+ )
+
+ has_skills = bool(all_skills)
+ self.skill_no_data_label.set_visible(not has_skills)
+
+ for i, slot in enumerate(self._skill_slots):
+ if i < len(all_skills):
+ sk = all_skills[i]
+ t_cnt = skills_today.get(sk, 0)
+ w_cnt = week_totals.get(sk, 0)
+ slot["name"].set_markup(f'/{sk}')
+ slot["today"].set_text(f"{t_cnt}x heute" if t_cnt > 0 else "—")
+ slot["week"].set_text(f"{w_cnt}x/Wo")
+ slot["row"].set_no_show_all(False)
+ slot["row"].show_all()
+ else:
+ slot["row"].hide()
+
+ # Timeline — token-based costs
+ timeline = get_usage_timeline()
+ max_cost = max((t["cost_est"] for t in timeline), default=0.01) or 0.01
+ for i, slot in enumerate(self._cost_timeline_slots):
+ if i < len(timeline):
+ t = timeline[i]
+ slot["date"].set_text(t["date"])
+ bar_len = max(1, int((t["cost_est"] / max_cost) * 25))
+ slot["bar"].set_markup(
+ f'{"\u2588" * bar_len}'
+ )
+ slot["info"].set_text(
+ f'{t["calls"]} calls ${t["cost_est"]:.3f}'
+ )
+ slot["row"].set_no_show_all(False)
+ slot["row"].show_all()
+ else:
+ slot["row"].hide()
+ except Exception:
+ log.exception("cost refresh failed")
+ return True
+
+ # -----------------------------------------------------------------------
+ # Save / Reset
+ # -----------------------------------------------------------------------
+ def on_save(self, _button):
+ """Save all settings from the panel to config files."""
+ try:
+ settings = read_settings()
+
+ # Model
+ model = self.model_combo.get_active_text()
+ if model:
+ settings["model"] = model
+
+ # Env vars
+ if "env" not in settings:
+ settings["env"] = {}
+
+ # Autonomy mode (custom env var)
+ autonomy = self.autonomy_combo.get_active_text()
+ if autonomy:
+ settings["env"]["CLAUDE_AUTONOMY_MODE"] = autonomy.lower()
+
+ # Max subagents (custom env var)
+ subagents = int(self.subagents_adj.get_value())
+ settings["env"]["CLAUDE_MAX_SUBAGENTS"] = str(subagents)
+
+ # Tool Budget
+ budget = int(self.budget_adj.get_value())
+ settings["env"]["SLASH_COMMAND_TOOL_CHAR_BUDGET"] = str(budget)
+
+ # Agent Teams
+ settings["env"]["CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"] = (
+ "1" if self.teams_switch.get_active() else "0"
+ )
+
+ # Tool Search
+ ts_val = self.tool_search_combo.get_active_text()
+ if ts_val:
+ settings["env"]["ENABLE_TOOL_SEARCH"] = ts_val
+
+ # enableAllProjectMcpServers
+ settings["enableAllProjectMcpServers"] = (
+ self.project_mcp_switch.get_active()
+ )
+
+ # Status Line
+ if self.statusline_switch.get_active():
+ if "statusLine" not in settings:
+ settings["statusLine"] = {
+ "type": "command",
+ "command": "~/.claude/statusline.sh",
+ }
+ else:
+ settings.pop("statusLine", None)
+
+ # MCP Server toggles — store disabled servers separately, never delete
+ if "mcpServers" in settings:
+ if "_disabled" not in settings:
+ settings["_disabled"] = {}
+ for name, sw in self.mcp_switches.items():
+ if not sw.get_active():
+ # Move to _disabled (preserve config for re-enable)
+ if name in settings["mcpServers"]:
+ settings["_disabled"][name] = settings["mcpServers"].pop(name)
+ else:
+ # Re-enable: move back from _disabled if it was there
+ if name in settings.get("_disabled", {}):
+ settings["mcpServers"][name] = settings["_disabled"].pop(name)
+ # Clean up empty _disabled
+ if not settings["_disabled"]:
+ del settings["_disabled"]
+
+ # Sync voicemode systemd service with MCP toggle state
+ if "voicemode" in settings.get("mcpServers", {}):
+ subprocess.Popen(["systemctl", "--user", "start", "voicemode-edge-tts"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ elif "voicemode" in settings.get("_disabled", {}):
+ subprocess.Popen(["systemctl", "--user", "stop", "voicemode-edge-tts"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+ # Hook timeouts and async flags
+ for hw in self.hook_widgets:
+ info = hw["info"]
+ new_timeout = int(hw["timeout_adj"].get_value())
+ new_async = hw["async_switch"].get_active()
+
+ event = info["event"]
+ gi_idx = info["group_index"]
+
+ if event in settings.get("hooks", {}) and gi_idx < len(
+ settings["hooks"][event]
+ ):
+ group = settings["hooks"][event][gi_idx]
+ for hook in group.get("hooks", []):
+ hook["timeout"] = new_timeout
+ if new_async:
+ hook["async"] = True
+ elif "async" in hook:
+ del hook["async"]
+
+ write_settings(settings)
+
+ # Coaching rate limit
+ new_rate = int(self.coaching_adj.get_value())
+ write_coaching_rate_limit(new_rate)
+
+ # Voice service (fire-and-forget to avoid blocking UI)
+ try:
+ if self.voice_switch.get_active():
+ subprocess.Popen(
+ ["systemctl", "--user", "start", "voicemode-edge-tts"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ self.voice_status.set_text(" Running")
+ else:
+ subprocess.Popen(
+ ["systemctl", "--user", "stop", "voicemode-edge-tts"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ self.voice_status.set_text(" Stopped")
+ except Exception:
+ log.exception("voice stop")
+
+ self._set_status("Gespeichert!", "status-saved")
+
+ except Exception as e:
+ log.exception("save handler")
+ self._set_status(f"Fehler: {e}", "status-error")
+
+ def on_reset(self, _button):
+ """Reload settings from disk and reset ALL UI widgets."""
+ self.settings = read_settings()
+ env = self.settings.get("env", {})
+
+ # Model
+ models = ["opus", "sonnet", "haiku"]
+ current = self.settings.get("model", "opus")
+ if current in models:
+ self.model_combo.set_active(models.index(current))
+
+ # Autonomy — items are capitalized, map via lowercase index
+ autonomy_modes = ["balanced", "sprint", "conserve"]
+ current_auto = env.get("CLAUDE_AUTONOMY_MODE", "balanced").lower()
+ if current_auto in autonomy_modes:
+ self.autonomy_combo.set_active(autonomy_modes.index(current_auto))
+ else:
+ self.autonomy_combo.set_active(0) # fallback to "Balanced"
+
+ # Max Subagents
+ self.subagents_adj.set_value(int(env.get("CLAUDE_MAX_SUBAGENTS", "8")))
+
+ # Budget
+ self.budget_adj.set_value(int(env.get("SLASH_COMMAND_TOOL_CHAR_BUDGET", "10000")))
+
+ # Teams
+ self.teams_switch.set_active(env.get("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "0") == "1")
+
+ # Tool Search
+ ts_options = ["auto:5", "auto:10", "manual", "off"]
+ current_ts = env.get("ENABLE_TOOL_SEARCH", "auto:5")
+ if current_ts in ts_options:
+ self.tool_search_combo.set_active(ts_options.index(current_ts))
+
+ # Project MCP
+ self.project_mcp_switch.set_active(self.settings.get("enableAllProjectMcpServers", False))
+
+ # Status Line
+ self.statusline_switch.set_active("statusLine" in self.settings)
+
+ # MCP Switches — all active (config = enabled)
+ mcp_servers = self.settings.get("mcpServers", {})
+ for name, sw in self.mcp_switches.items():
+ sw.set_active(name in mcp_servers)
+
+ # Coaching
+ rate = read_coaching_rate_limit()
+ self.coaching_adj.set_value(rate)
+
+ self._set_status("Zurueckgesetzt", "status-saved")
+
+ # -----------------------------------------------------------------------
+ # Helpers
+ # -----------------------------------------------------------------------
+ @staticmethod
+ def _label(text: str) -> Gtk.Label:
+ """Create a left-aligned label for settings rows."""
+ lbl = Gtk.Label(label=text, xalign=0)
+ lbl.set_width_chars(16)
+ return lbl
+
+ def _set_status(self, text: str, css_class: str = "") -> None:
+ """Set status bar text with optional CSS class, auto-clears after 4s."""
+ self.status_label.set_text(text)
+ ctx = self.status_label.get_style_context()
+ for cls in ["status-saved", "status-error"]:
+ ctx.remove_class(cls)
+ if css_class:
+ ctx.add_class(css_class)
+ GLib.timeout_add_seconds(4, self._clear_status)
+
+ def _clear_status(self) -> bool:
+ self.status_label.set_text("")
+ return False
+
+
+def _build_tray_indicator(win: ControlPanel) -> AyatanaAppIndicator3.Indicator:
+ """Create a system tray icon with show/hide/quit menu and quick-stats."""
+ from pathlib import Path as P
+ icon_dir = str(P(__file__).resolve().parent)
+
+ indicator = AyatanaAppIndicator3.Indicator.new(
+ "claude-code-panel",
+ "claude-panel-icon",
+ AyatanaAppIndicator3.IndicatorCategory.APPLICATION_STATUS,
+ )
+ indicator.set_icon_theme_path(icon_dir)
+ indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
+ indicator.set_title("Claude Code Panel")
+
+ menu = Gtk.Menu()
+
+ # --- Quick Stats (updated on menu open) ---
+ item_cost = Gtk.MenuItem(label="Kosten heute: ...")
+ item_cost.set_sensitive(False)
+ menu.append(item_cost)
+
+ item_sessions_summary = Gtk.MenuItem(label="Sessions: ...")
+ item_sessions_summary.set_sensitive(False)
+ menu.append(item_sessions_summary)
+
+ # Dynamic per-session items (max 5)
+ session_items: list[Gtk.MenuItem] = []
+ for _ in range(5):
+ item = Gtk.MenuItem(label="")
+ item.set_sensitive(False)
+ item.set_no_show_all(True)
+ item.hide()
+ menu.append(item)
+ session_items.append(item)
+
+ menu.append(Gtk.SeparatorMenuItem())
+
+ # --- Verstecken (window auto-shows on menu open, so only hide needed) ---
+ item_hide = Gtk.MenuItem(label="Verstecken")
+ item_hide.connect("activate", lambda _: win.hide())
+ menu.append(item_hide)
+
+ # --- Open specific tabs ---
+ item_settings = Gtk.MenuItem(label="Einstellungen")
+ def _show_tab(_, tab_idx):
+ win.show_all()
+ win.present()
+ win.notebook.set_current_page(tab_idx)
+ item_settings.connect("activate", _show_tab, 1)
+ menu.append(item_settings)
+
+ item_monitor = Gtk.MenuItem(label="Monitor")
+ item_monitor.connect("activate", _show_tab, 3)
+ menu.append(item_monitor)
+
+ menu.append(Gtk.SeparatorMenuItem())
+
+ # --- New session ---
+ item_new = Gtk.MenuItem(label="Neue Session")
+ item_new.connect(
+ "activate",
+ lambda _: subprocess.Popen(
+ ["kitty", "-e", "claude"], start_new_session=True
+ ),
+ )
+ menu.append(item_new)
+
+ menu.append(Gtk.SeparatorMenuItem())
+
+ # --- Quit ---
+ item_quit = Gtk.MenuItem(label="Beenden")
+ item_quit.connect("activate", lambda _: Gtk.main_quit())
+ menu.append(item_quit)
+
+ menu.show_all()
+ indicator.set_menu(menu)
+
+ def _update_session_menu_items(sessions: list[dict]) -> None:
+ """Update the per-session menu items and summary."""
+ active = [s for s in sessions if s.get("status") in ("working", "ready")]
+ working = sum(1 for s in active if s["status"] == "working")
+ ready = sum(1 for s in active if s["status"] == "ready")
+
+ # Summary line
+ if active:
+ parts = []
+ if working:
+ parts.append(f"{working} arbeitet")
+ if ready:
+ parts.append(f"{ready} bereit")
+ item_sessions_summary.set_label("Sessions: " + " / ".join(parts))
+ else:
+ item_sessions_summary.set_label("Sessions: keine aktiv")
+
+ # Per-session items
+ for i, item in enumerate(session_items):
+ if i < len(active):
+ s = active[i]
+ emoji = "\u25cf" if s["status"] == "working" else "\u25cb"
+ status_text = "ARBEITET" if s["status"] == "working" else "BEREIT"
+ item.set_label(f" {emoji} {s['short_name']} \u2014 {status_text}")
+ item.show()
+ else:
+ item.hide()
+
+ # Tray label: compact "XW/YB"
+ if working or ready:
+ indicator.set_label(f"{working}W/{ready}B", "")
+ else:
+ indicator.set_label("", "")
+
+ # On menu show: auto-show window + refresh stats (1-click UX)
+ def _on_menu_show(_menu):
+ if not win.get_visible():
+ win.show_all()
+ win.present()
+ cost_data = get_daily_cost()
+ if "error" not in cost_data:
+ item_cost.set_label(
+ f"Kosten heute: {format_cost(cost_data['cost_estimate_usd'])}"
+ )
+ else:
+ item_cost.set_label("Kosten heute: N/A")
+ # Refresh session status on menu open
+ try:
+ sessions = _scan_all_sessions()
+ _update_session_menu_items(sessions)
+ except Exception:
+ log.exception("tray menu show")
+
+ menu.connect("show", _on_menu_show)
+
+ # Periodic tray status refresh (every 15s)
+ def _refresh_tray_status() -> bool:
+ try:
+ sessions = _scan_all_sessions()
+ _update_session_menu_items(sessions)
+ except Exception:
+ log.exception("tray session refresh")
+ return True # keep timer alive
+
+ GLib.timeout_add_seconds(15, _refresh_tray_status)
+
+ return indicator
+
+
+def _ensure_companion_panel():
+ """Start Sidecar Panel if not already running.
+
+ Uses atomic spawn guard (O_CREAT|O_EXCL) to prevent race conditions.
+ The guard file is cleaned up after 60s by a one-shot timer.
+ """
+ companion = Path.home() / "Projekte" / "Sidecar" / "gui" / "main.py"
+ companion_lock = "/tmp/sidecar-panel.lock"
+ spawn_guard = "/tmp/sidecar-panel.spawning"
+ if not companion.exists():
+ return
+
+ # Check if companion holds its flock
+ try:
+ fd = os.open(companion_lock, os.O_CREAT | os.O_WRONLY, 0o644)
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ # Got the lock → companion is NOT running → release and spawn
+ fcntl.flock(fd, fcntl.LOCK_UN)
+ except BlockingIOError:
+ os.close(fd)
+ return # companion is alive
+ os.close(fd)
+ except OSError:
+ return
+
+ # Atomic spawn guard — prevents double-spawn from autostart + watchdog
+ try:
+ guard_fd = os.open(spawn_guard, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
+ os.close(guard_fd)
+ except FileExistsError:
+ return # someone else is already spawning
+ except OSError:
+ return
+
+ try:
+ subprocess.Popen(
+ ["python3", str(companion)],
+ cwd=str(companion.parent),
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ except Exception:
+ pass
+
+ # Clean up spawn guard after 60s (companion should have its lock by then)
+ def _cleanup_guard():
+ try:
+ os.unlink(spawn_guard)
+ except OSError:
+ pass
+ return False
+ GLib.timeout_add_seconds(60, _cleanup_guard)
+
+
+def _companion_watchdog():
+ """Periodically check and restart companion panel (every 30s)."""
+ _ensure_companion_panel()
+ return True # keep timer running
+
+
+_lock_file = None
+
+
+def _acquire_singleton_lock():
+ """Ensure only one panel instance runs at a time.
+
+ Uses flock only — auto-released on crash, no PID check needed.
+ Opens with O_CREAT|O_WRONLY to avoid inode races from unlink+recreate.
+ """
+ global _lock_file
+ lock_path = "/tmp/claude-panel.lock"
+ fd = os.open(lock_path, os.O_CREAT | os.O_WRONLY, 0o644)
+ _lock_file = os.fdopen(fd, "w")
+ try:
+ fcntl.flock(_lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ _lock_file.truncate(0)
+ _lock_file.write(str(os.getpid()))
+ _lock_file.flush()
+ except BlockingIOError:
+ log.warning("Panel already running — exiting duplicate instance.")
+ sys.exit(0)
+
+
+def main():
+ _acquire_singleton_lock()
+ _ensure_companion_panel()
+ GLib.timeout_add_seconds(30, _companion_watchdog)
+ win = ControlPanel()
+
+ # Close button hides to tray instead of quitting
+ win.connect("delete-event", lambda w, e: (w.hide(), True)[-1])
+
+ indicator = _build_tray_indicator(win)
+
+ win.show_all()
+
+ # Move to portrait monitor if available
+ display = Gdk.Display.get_default()
+ if display:
+ n_monitors = display.get_n_monitors()
+ for i in range(n_monitors):
+ mon = display.get_monitor(i)
+ geo = mon.get_geometry()
+ if geo.width < geo.height: # portrait mode
+ win.move(geo.x + 20, geo.y + 20)
+ break
+
+ Gtk.main()
+
+
+if __name__ == "__main__":
+ main()