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()