smlflg commited on
Commit
bbc65cd
·
verified ·
1 Parent(s): 462d84c

Initial public upload from Projekte/ClaudeCodePanel

Browse files
.codex/config.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Coding MCPs: project-local registration (2026-09-07).
2
+
3
+ [mcp_servers.serena]
4
+ command = "/home/smlflg/.local/share/coding-mcp/serena/.venv/bin/serena"
5
+ args = ["start-mcp-server", "--context=codex", "--project", "/home/smlflg/Projekte/ClaudeCodePanel", "--enable-web-dashboard", "false", "--open-web-dashboard", "false", "--enable-gui-log-window", "false"]
6
+ cwd = "/home/smlflg/Projekte/ClaudeCodePanel"
7
+ enabled = true
8
+ startup_timeout_sec = 120
9
+ tool_timeout_sec = 180
10
+
11
+ [mcp_servers.serena.env]
12
+ "SERENA_HOME" = "/home/smlflg/.local/share/coding-mcp/serena/state"
13
+
14
+ [mcp_servers.gitnexus]
15
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
16
+ args = ["/home/smlflg/.local/share/coding-mcp/gitnexus/node_modules/gitnexus/dist/cli/index.js", "mcp"]
17
+ cwd = "/home/smlflg/Projekte/ClaudeCodePanel"
18
+ enabled = true
19
+ startup_timeout_sec = 120
20
+ tool_timeout_sec = 180
21
+
22
+ [mcp_servers.gitnexus.env]
23
+ "GITNEXUS_HOME" = "/home/smlflg/.local/share/coding-mcp/gitnexus/runtime"
24
+
25
+ [mcp_servers.context7]
26
+ command = "/home/smlflg/.nvm/versions/node/v24.13.0/bin/node"
27
+ args = ["/home/smlflg/.local/share/coding-mcp/context7/node_modules/@upstash/context7-mcp/dist/index.js"]
28
+ cwd = "/home/smlflg/Projekte/ClaudeCodePanel"
29
+ enabled = true
30
+ startup_timeout_sec = 120
31
+ tool_timeout_sec = 180
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.backup-*
5
+ *.bak
6
+ .venv/
7
+ venv/
8
+ *.sqlite3
9
+ *.db
10
+ .env
11
+ node_modules/
12
+ .DS_Store
13
+ *.egg-info/
14
+ dist/
15
+ build/
16
+ CLAUDE.md
17
+ REVIEW_*.md
18
+ CLOUDBOT_RESEARCH.md
FOR_SMLFLG.md ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ClaudeCodePanel — Wie dein Panel funktioniert
2
+
3
+ ## Die Kurzversion
4
+
5
+ Dein Panel ist wie ein **Cockpit fuer Claude Code**. Statt 15 Terminal-Fenster
6
+ und Config-Dateien zu jonglieren, hast du ein GTK3-Fenster mit 7 Tabs
7
+ und ein Tray-Icon das immer da ist. Denk an ein Auto-Armaturenbrett:
8
+ Tacho (Monitor), Bordcomputer (Hub), Fehlerlampen (Logs), Navigationsliste (Sessions).
9
+
10
+ **Stack:** Python 3.12 + PyGObject (GTK3) + Catppuccin Theming + systemd Service
11
+
12
+ ---
13
+
14
+ ## Architektur — 14 Module
15
+
16
+ ### panel.py (2373 Zeilen) — Der Dirigent
17
+ Das Hauptfenster. Baut alle Tabs zusammen, startet die Timer, verwaltet das Tray-Icon.
18
+ Wie ein Dirigent: spielt selbst kein Instrument, aber koordiniert alle anderen.
19
+
20
+ **Key:** `ControlPanel` Klasse mit `_build_*_tab()` Methoden, `_refresh_hub()`, `_refresh_monitor()`.
21
+
22
+ ### monitor.py (729 Zeilen) — Der Daten-Butler
23
+ Holt alle Zahlen: Kosten, Top-Tools, aktive Sessions, Timeline.
24
+ Hat einen TTL-Cache (30 Sekunden) — fragt nicht staendig die Festplatte.
25
+
26
+ **Key:** `get_daily_cost()`, `get_top_tools()`, `get_active_sessions()`. Nutzt MyAIGame's `tui.data.*` Module.
27
+
28
+ ### log_viewer.py (435 Zeilen) — Der Protokollant
29
+ Zeigt heutige Usage-Eintraege und Coaching-Log. Filterbar nach Tools/Errors/Hooks.
30
+
31
+ **Key:** `build_logs_tab()`, `refresh_logs()`. Liest `~/.claude/usage/YYYY-MM-DD.jsonl`.
32
+
33
+ ### session_browser.py (1039 Zeilen) — Das Telefonbuch
34
+ Alle Claude-Sessions durchsuchbar. Zeigt Projekt, Vorschau, Groesse. Resume-Button oeffnet kitty.
35
+
36
+ **Key:** `build_sessions_tab()`, `_scan_all_sessions()`, `_on_resume_clicked()`.
37
+
38
+ ### process_manager.py (588 Zeilen) — Der Hausmeister
39
+ Scannt `ps aux` nach Claude-relevanten Prozessen. Zeigt RAM, CPU, Uptime.
40
+ Kill-Button pro Prozess + "Kill All Ghosts" fuer Prozesse >24h.
41
+
42
+ **Key:** `build_processes_tab()`, `_scan_processes()`, Ghost-Detection via `/proc/<pid>/stat`.
43
+
44
+ ### theme.py (620 Zeilen) — Der Maler
45
+ Erkennt ob COSMIC Desktop dunkel oder hell ist, generiert passendes CSS.
46
+ Catppuccin Mocha (dark) / Latte (light). Wechselt live wenn du das System-Theme aenderst.
47
+
48
+ **Key:** `is_dark_mode()` liest `~/.config/cosmic/com.system76.CosmicTheme.Mode/v1/is_dark`.
49
+ `setup_theme_watcher()` nutzt `Gio.FileMonitor` fuer Live-Updates.
50
+
51
+ ### config_io.py (168 Zeilen) — Der Tresorwaerter
52
+ Liest und schreibt `~/.claude/settings.json` **atomar** — nie direkt.
53
+ Immer: Backup → Temp-Datei → `os.replace()`. So geht nie was kaputt.
54
+
55
+ **Key:** `read_settings()`, `write_settings()`, `update_setting("key.path", value)`.
56
+
57
+ ### swarm_tab.py (974 Zeilen) — Der Lageplaner
58
+ Zeigt die laufende Agent Team Pipeline in Echtzeit. Liest `~/.claude/teams/` und
59
+ `~/.claude/tasks/` und stellt Scout→Weaver→Builder→Validator→Pruefer als Karten dar.
60
+
61
+ **Key:** `build_swarm_tab()`, `_scan_teams()`, `_refresh_swarm()`.
62
+
63
+ ### swarm_visual.py (708 Zeilen) — Der Kartograf
64
+ Generiert HTML-Seiten mit interaktiven Agent-Kommunikationsgraphen: Hub-and-Spoke-Layout,
65
+ Bezier-Kurven, Partikel-Animationen. Wird von swarm_tab.py aufgerufen und in WebKit2 eingebettet.
66
+
67
+ **Key:** `generate_swarm_html()`, Bezier + Partikel-Rendering via Canvas.
68
+
69
+ ### event_tab.py (426 Zeilen) — Der Horcher
70
+ Live-Viewer fuer Hook-Events aus `~/.claude/events/YYYY-MM-DD.jsonl`. Filterbar
71
+ nach Event-Typ, Auto-Scroll wenn neue Events reinkommen.
72
+
73
+ **Key:** `build_events_tab()`, `_tail_events()`, Filter-Logik nach Typ/Quelle.
74
+
75
+ ### shortcut_counter_tab.py (450 Zeilen) — Der Strichlisten-Fuehrer
76
+ Zeigt Keyboard-Shortcut-Statistiken aus der ShortcutCounter-Datenbank. Wie oft hast
77
+ du Ctrl+C vs. Ctrl+Z genutzt? Welcher Shortcut spart am meisten Zeit?
78
+
79
+ **Key:** `build_shortcut_tab()`, liest ShortcutCounter SQLite DB.
80
+
81
+ ### project_dashboard_tab.py (254 Zeilen) — Das Fenster
82
+ Bettet das Project Dashboard als WebKit2-Webview direkt ins Panel ein, statt
83
+ einen Browser oeffnen zu muessen. Ein Tab = ein komplettes Web-Dashboard.
84
+
85
+ **Key:** `build_project_dashboard_tab()`, `WebKit2.WebView` Setup + Reload-Button.
86
+
87
+ ### utils.py (33 Zeilen) — Der Werkzeugkasten
88
+ Kleine Hilfsfunktionen die ueberall gebraucht werden (Format-Helpers, Pfad-Utilities).
89
+ Klein aber da wenn man ihn braucht.
90
+
91
+ **Key:** Shared Utilities fuer alle anderen Module.
92
+
93
+ ### tools-gui/ (623 Zeilen) — Das Zweite Cockpit
94
+ Separates GTK3-Fenster mit 3 eigenen Tabs: Kosten-Analyse, Session-Uebersicht, Tool-Statistiken.
95
+ Laeuft als eigenstaendiges Fenster neben dem Hauptpanel (main.py + 3 Tab-Module).
96
+
97
+ **Key:** `tools-gui/main.py` als Einstiegspunkt, 3 Tab-Module fuer costs/sessions/tools.
98
+
99
+ ---
100
+
101
+ ## Wie die Tabs zusammenhaengen
102
+
103
+ ```
104
+ panel.py (Dirigent)
105
+ ├── importiert monitor.py → liefert Daten fuer Hub + Monitor Tab
106
+ ├── importiert log_viewer.py → baut Logs Tab
107
+ ├── importiert session_browser.py → baut Sessions Tab
108
+ ├── importiert process_manager.py → baut Prozesse Tab
109
+ ├── importiert swarm_tab.py → baut Agent Swarm Tab
110
+ ├── importiert swarm_visual.py → generiert Swarm-Graphen (HTML)
111
+ ├── importiert event_tab.py → baut Events Tab
112
+ ├── importiert shortcut_counter_tab.py → baut Shortcut Counter Tab
113
+ ├── importiert project_dashboard_tab.py → baut Project Dashboard Tab (WebKit2)
114
+ ├── importiert theme.py → CSS + Theme Watcher
115
+ └── importiert config_io.py → Settings + Hooks Tabs lesen/schreiben Config
116
+ ```
117
+
118
+ Jedes Modul ist **eigenstaendig** — kann theoretisch allein funktionieren.
119
+ panel.py ist nur der Rahmen der sie zusammenbringt.
120
+
121
+ ---
122
+
123
+ ## Timer-System — Das Herzschlag-System
124
+
125
+ Dein Panel hat 6 Timer die wie Herzschlaege funktionieren. Jeder Timer
126
+ ruft periodisch eine Funktion auf die Daten aktualisiert:
127
+
128
+ | Timer | Intervall | Funktion | Was wird aktualisiert? |
129
+ |-------|-----------|----------|----------------------|
130
+ | Hub | 30s | `_refresh_hub()` | Kosten, Top-Tools, Sessions |
131
+ | Monitor-Start | 15s | `_start_monitor_timer()` | Startet Monitor-Timer |
132
+ | Logs | 10s | `refresh_logs()` | Usage + Coaching Eintraege |
133
+ | Sessions | 60s | `refresh_sessions()` | Session-Liste |
134
+ | Prozesse | 30s | `refresh_processes()` | Prozess-Scan |
135
+ | Monitor | 30s | `_refresh_monitor()` | Timeline, Skills |
136
+
137
+ **Warum gestaffelt?** Damit nicht alle gleichzeitig feuern und die UI einfriert.
138
+ Logs sind am schnellsten (10s) weil Usage-Daten sich oft aendern.
139
+ Sessions am langsamsten (60s) weil sich Sessions selten aendern.
140
+
141
+ **Was wenn ein Timer haengt?** Jeder Timer returned `True` um am Leben zu bleiben.
142
+ Wenn einer Exception wirft, faengt `try/except` es ab und der Timer laeuft weiter.
143
+ Worst case: ein Tab zeigt veraltete Daten, aber das Panel stuerzt nicht ab.
144
+
145
+ ---
146
+
147
+ ## Daten-Flow
148
+
149
+ ```
150
+ ~/.claude/usage/YYYY-MM-DD.jsonl ──→ monitor.py (Cache 30s) ──→ Hub Tab (Kosten, Tools)
151
+ └──→ log_viewer.py ──→ Logs Tab (Eintraege)
152
+
153
+ ~/.claude/projects/*/ ────────────→ monitor.py + session_browser ──→ Sessions Tab
154
+
155
+ ps aux ────────────────────────────→ process_manager.py ──→ Prozesse Tab
156
+
157
+ ~/.claude/settings.json ──────────→ config_io.py ──→ Settings + Hooks Tab
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Theme-System
163
+
164
+ 1. **Detection:** Liest `~/.config/cosmic/com.system76.CosmicTheme.Mode/v1/is_dark`
165
+ - `true` → Catppuccin Mocha (dunkle Palette)
166
+ - `false` → Catppuccin Latte (helle Palette)
167
+ - Datei fehlt → Fallback auf GTK `gtk-application-prefer-dark-theme`
168
+
169
+ 2. **CSS:** `build_css(palette)` generiert ~150 Zeilen CSS aus der Palette
170
+
171
+ 3. **Live-Switch:** `Gio.FileMonitor` beobachtet die COSMIC Config-Datei.
172
+ Aendert sich das System-Theme, wird `apply_theme()` via `GLib.idle_add` aufgerufen.
173
+
174
+ ---
175
+
176
+ ## Config-System — Atomare Writes
177
+
178
+ Warum nicht einfach `json.dump(data, open(file, "w"))`?
179
+ Weil: wenn der Prozess mittendrin stirbt, hast du eine kaputte halbe Datei.
180
+
181
+ **Stattdessen (config_io.py):**
182
+ 1. **Backup:** `settings.json` → `settings.json.backup-20260302-143000`
183
+ 2. **Temp:** Schreibe in `settings-XXXX.tmp` (gleicher Ordner)
184
+ 3. **Rename:** `os.replace(tmp, settings.json)` — atomar auf Linux!
185
+
186
+ Entweder hast du die alte ODER die neue Datei. Nie eine kaputte.
187
+
188
+ ---
189
+
190
+ ## Sprint 2026-03-04 Learnings
191
+
192
+ ### Das Wichtigste zuerst: Deine vorherige Arbeit war solide.
193
+
194
+ 4 von 5 Warnings waren **bereits gefixt** bevor der Sprint anfing (Commit `67a4362`).
195
+ Du hast einen 5-Agenten-Sprint geplant fuer... einen einzigen verbleibenden Fix (subprocess.run → Popen).
196
+ Das Panel ist zu **95%+ fertig**. Was wirklich noch offen ist: 4 Suggestions (nice-to-have), kein Critical.
197
+
198
+ ### Muster: Erst schauen, dann planen.
199
+
200
+ Tendency erkannt: Samuel plant gross (5 Agents, detaillierter PLAN-File) **bevor** er den aktuellen Stand prueft.
201
+ Regel fuer dich: **`/check-state` vor jedem Sprint-Start.** Eine Minute Status-Check spart eine Stunde Overhead.
202
+
203
+ Konkret: Bevor du Tasks erstellst, kurz pruefen:
204
+ - Welche Punkte der Checkliste sind schon erledigt? (PLAN-File lesen)
205
+ - Laeuft der Service gerade fehlerfrei? (`systemctl --user status claude-panel.service`)
206
+ - Wie gross ist der echte Rueckstand? (1 Fix ≠ 5-Agenten-Aufwand)
207
+
208
+ ### Was gut lief (verstaerken!)
209
+
210
+ - **Dokumentations-Disziplin:** PLAN-File erstellt, Review-Findings dokumentiert, Entscheidungen festgehalten. Das ist wertvoll.
211
+ - **Commit-Hygiene:** Criticals sofort gefixt und committed. Kein "ich mach das spaeter".
212
+ - **Code-Review Workflow:** Sonnet Review + MiniMax Zweitmeinung. Zwei Perspektiven fangen mehr.
213
+ - **Architektur-Entscheidungen klar begruendet:** `**/*.jsonl` fuer rekursive Suche, `"anthropic"` Key-Aggregation, Fingerprint-Mechanismus — alles dokumentiert.
214
+
215
+ ### Was noch aussteht (die echte Restliste)
216
+
217
+ Nur Suggestions, kein muss:
218
+ 1. `monitor.py:~662` — `sorted()` mit stat()-Lambda vereinfachen
219
+ 2. `log_viewer.py:~456` — unbenutzten `sort_model` Parameter entfernen
220
+ 3. `panel.py:~812` — `_DETECTOR_LAYOUT` Naming-Konvention
221
+ 4. `log_viewer.py` — Status-Farben aus Theme-Palette statt hardcoded
222
+
223
+ Das Panel laeuft. Diese Fixes machen es sauberer, nicht funktionsfaehiger.
224
+
225
+ ---
226
+
227
+ ## Bekannte Gotchas
228
+
229
+ 1. **CPU Spin-Loop (GEFIXT):** `GLib.idle_add(refresh_logs)` mit `return True` =
230
+ Endlosschleife. Fix: One-Shot Wrapper mit `return False`. Regel: `idle_add`
231
+ Callbacks muessen IMMER `False` returnen.
232
+
233
+ 2. **Ghost-Prozesse:** MCP-Server (opencode-mcp, gemini-mcp) werden pro Session
234
+ gestartet aber nie beendet. Der Prozesse-Tab zeigt sie als "Ghosts" (>24h).
235
+
236
+ 3. **MyAIGame Dependency:** `monitor.py` importiert `tui.data.usage_reader` aus
237
+ `~/Projekte/MyAIGame/`. Wenn das Projekt nicht existiert, fallen Kosten-
238
+ und Tool-Statistiken auf Null zurueck (graceful fallback, kein Crash).
239
+
240
+ 4. **COSMIC-only Theme Detection:** Auf nicht-COSMIC-Desktops funktioniert nur
241
+ der GTK-Fallback. Die Catppuccin-Farben bleiben, aber Live-Switching nur per GTK.
242
+
243
+ 5. **Singleton Lock (GEFIXT):** Frueher konnten mehrere Panel-Instanzen gleichzeitig
244
+ laufen (systemd restart + manueller Start). Fix: `fcntl.flock` auf `/tmp/claude-panel.lock`.
245
+ Zweite Instanz erkennt Lock und beendet sich mit `sys.exit(0)`.
PLAN_2026-03-13_quickwins.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Plan: ClaudeCodePanel Quick Wins
2
+
3
+ Erstellt: 2026-03-13 — nach Deep Review + Performance Fixes
4
+
5
+ ## Erledigt
6
+
7
+ - [x] `_scan_session_jsonls()` in Background Thread (437ms UI-Freeze eliminiert)
8
+ - [x] `_scan_processes()` in Background Thread (39ms UI-Freeze eliminiert)
9
+ - [x] Commit `3d4f46d` gepusht
10
+ - [x] Baseline-Messungs-Regel in `~/.claude/CLAUDE.md` hinzugefügt
11
+ - [x] Shortcuts-Tab: 43→108 Shortcuts (ClaudeCLI +16, ClaudeCLI_Vim +20, COSMIC +14, Workspaces +10)
12
+ - [x] 7 falsche COSMIC-Beschreibungen korrigiert (waren GNOME-Defaults statt COSMIC)
13
+ - [x] Case-insensitive Matching fix (DB: TAB vs Config: Tab)
14
+ - [x] 2 neue Anti-Pattern-Regeln in CLAUDE.md
15
+ - [x] Commits `20b7ba0` + `fd9b8ef` gepusht
16
+
17
+ ## Offen — Quick Wins (je <30 Min)
18
+
19
+ ### 1. `constants.py` extrahieren (10 Min)
20
+ 5 Module definieren PROJECTS_DIR, USAGE_DIR redundant:
21
+ - `monitor.py:66-67`
22
+ - `session_browser.py:40`
23
+ - `log_viewer.py:19`
24
+ - `config_io.py:14`
25
+
26
+ Neues `constants.py` mit allen Pfaden, Module importieren daraus.
27
+
28
+ ### 2. `providers.jsonl` Cache-Sharing (15 Min)
29
+ `log_viewer.py:172` liest `providers.jsonl` eigenständig.
30
+ `monitor.py:600` cached dieselbe Datei bereits.
31
+ Fix: log_viewer importiert monitor.get_provider_costs() oder neue
32
+ monitor.get_provider_entries_today() Funktion.
33
+
34
+ ### 3. WebKit2 lazy-loading (30 Min)
35
+ `swarm_tab.py` und `project_dashboard_tab.py` spawnen je einen WebProcess
36
+ beim Start (~50-100MB RAM pro Stück). Fix: WebView erst bei Tab-Switch
37
+ erstellen via `notebook.connect("switch-page", ...)`.
38
+
39
+ ## Offen — Features
40
+
41
+ ### 4. Ideen/Gedanken Tab (Parking Lot)
42
+ - Codex baut das Parking Lot Backend (parking_lot mit park/unpark/park-list)
43
+ - Panel braucht Tab zur Darstellung: Claude Code Plaene, eigene Plaene, geparkte Gedanken
44
+ - Samuel gibt Pfade wenn Codex fertig ist — FRAGEN nicht selbst suchen
45
+ - Existierendes Vorbild: `~/Projekte/MyAIGame/tui/adhs/parking.py` (SQLite-backed)
46
+
47
+ ### 5. Cloudbot Tab (groß)
48
+ Vollständig recherchiert in `CLOUDBOT_RESEARCH.md`.
49
+ MVP-Scope: Session Quality Scoring als `cloudbot_tab.py`.
README.md ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Claude Code Panel
2
+
3
+ A native GTK3 desktop panel for [Claude Code](https://claude.ai/code) on Linux. System tray icon, session browser, agent swarm monitor, cost tracking, event viewer — all in one place.
4
+
5
+ Built for Pop!\_OS / COSMIC Desktop, works on any Linux with GTK3.
6
+
7
+ ## Features
8
+
9
+ - **System Tray Icon** — always-on indicator with quick access menu (AyatanaAppIndicator)
10
+ - **Hub Tab** — daily cost, top tools, active sessions at a glance
11
+ - **Monitor Tab** — signal cards, usage timeline, provider cost breakdown, phase badge
12
+ - **Session Browser** — search, filter, and resume sessions with one click
13
+ - **Process Manager** — Claude-related processes (MCP servers, CLI), ghost detection, kill button
14
+ - **Agent Swarm Tab** — live view of Agent Team pipelines (Scout → Weaver → Builder → Validator → Prüfer)
15
+ - **Swarm Visual** — HTML agent communication graph (hub-and-spoke, Bézier curves, particles)
16
+ - **Event Viewer** — live hook events from `~/.claude/events/` with filters + auto-scroll
17
+ - **Shortcut Counter** — keyboard shortcut usage stats from ShortcutCounter DB
18
+ - **Project Dashboard** — embedded WebKit2 view of project dashboards
19
+ - **Log Viewer** — filterable usage + coaching log entries
20
+ - **Settings & Hooks** — read/write Claude Code config with atomic file writes
21
+ - **Cost Tracking** — daily token costs, 7-day bar chart, per-provider breakdown
22
+ - **Light/Dark Mode** — auto-detects COSMIC Desktop theme, Catppuccin Mocha/Latte, live switching
23
+ - **Singleton Lock** — only one instance runs at a time (fcntl.flock)
24
+ - **Portrait Monitor** — auto-moves window to portrait display if available
25
+
26
+ ## Requirements
27
+
28
+ - Python 3.10+
29
+ - GTK 3.0
30
+ - AyatanaAppIndicator3 (system tray)
31
+ - WebKit2 (optional, for Project Dashboard tab)
32
+
33
+ ```bash
34
+ # Pop!_OS / Ubuntu / Debian
35
+ sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-ayatanaappindicator3-0.1 gir1.2-webkit2-4.1
36
+ ```
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ git clone https://github.com/smlfg/ClaudeCodePanel.git
42
+ cd ClaudeCodePanel
43
+
44
+ # Run directly
45
+ python3 panel.py
46
+
47
+ # Or install as systemd user service (auto-start on login)
48
+ cp claude-panel.service ~/.config/systemd/user/
49
+ systemctl --user daemon-reload
50
+ systemctl --user enable --now claude-panel.service
51
+ ```
52
+
53
+ ## Architecture
54
+
55
+ ```
56
+ panel.py (2373 LOC) — Main window, 7+ tabs, tray icon, singleton lock
57
+ ├── monitor.py (729) — Data layer: costs, tools, sessions (TTL cache 30s)
58
+ ├── config_io.py (168) — Atomic config read/write (backup → temp → rename)
59
+ ├── theme.py (620) — COSMIC theme detection + Catppuccin CSS
60
+ ├── session_browser.py (1039) — Session list, search, resume
61
+ ├── process_manager.py (588) — Process scan, ghost detection, kill
62
+ ├── log_viewer.py (435) — Usage + coaching log viewer
63
+ ├── swarm_tab.py (974) — Agent Team pipeline viewer
64
+ ├── swarm_visual.py (708) — HTML agent communication graph
65
+ ├── event_tab.py (426) — Live hook event viewer
66
+ ├── shortcut_counter_tab.py (450) — Keyboard shortcut stats
67
+ ├── project_dashboard_tab.py (254) — Embedded project dashboard (WebKit2)
68
+ └── utils.py (33) — Helper functions
69
+ ```
70
+
71
+ ### tools-gui/ (separate window)
72
+
73
+ ```
74
+ tools-gui/main.py (83) — Standalone GTK3 window
75
+ ├── tabs/sessions_tab.py (146) — Session list with search
76
+ ├── tabs/costs_tab.py (216) — Daily costs + 7-day bar chart (Cairo)
77
+ └── tabs/tools_tab.py (178) — Tool + skill usage breakdown
78
+ ```
79
+
80
+ **Total: ~9,700 LOC across 19 Python files.**
81
+
82
+ ## Theme System
83
+
84
+ The panel auto-detects your desktop theme:
85
+
86
+ - **COSMIC Desktop**: reads `~/.config/cosmic/com.system76.CosmicTheme.Mode/v1/is_dark`
87
+ - **Other GTK desktops**: uses `gtk-application-prefer-dark-theme` setting
88
+
89
+ Switching your system theme updates the panel in real-time (no restart needed).
90
+
91
+ Color palettes: [Catppuccin Mocha](https://github.com/catppuccin/catppuccin) (dark) / [Catppuccin Latte](https://github.com/catppuccin/catppuccin) (light).
92
+
93
+ ## License
94
+
95
+ MIT
brightness_tab.py ADDED
@@ -0,0 +1,807 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Interactive Sonnenuhr control tab for ClaudeCodePanel."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import socket
8
+ import threading
9
+ import time
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ import gi
14
+
15
+ gi.require_version("Gtk", "3.0")
16
+ from gi.repository import GLib, Gtk, Pango
17
+
18
+ from theme import get_palette
19
+
20
+ STATE_PATH = Path("/tmp/sonnenuhr-state.json")
21
+ SOCKET_PATH = Path("/tmp/sonnenuhr.sock")
22
+ SOCKET_TIMEOUT_SECONDS = 2.0
23
+ SLIDER_DEBOUNCE_MS = 300
24
+ OFFSET_MIN = -50
25
+ OFFSET_MAX = 50
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Module-level widget refs
29
+ # ---------------------------------------------------------------------------
30
+ _header_label: Gtk.Label | None = None
31
+ _mode_badge: Gtk.Label | None = None
32
+ _refresh_button: Gtk.Button | None = None
33
+ _timeline_bar: Gtk.ProgressBar | None = None
34
+ _timeline_label: Gtk.Label | None = None
35
+ _brightness_value: Gtk.Label | None = None
36
+ _brightness_scale: Gtk.Scale | None = None
37
+ _temp_value: Gtk.Label | None = None
38
+ _temp_scale: Gtk.Scale | None = None
39
+ _temp_rgb: Gtk.Label | None = None
40
+ _displays_box: Gtk.Box | None = None
41
+ _footer_update: Gtk.Label | None = None
42
+ _footer_theme: Gtk.Label | None = None
43
+ _footer_status: Gtk.Label | None = None
44
+
45
+ _mode_buttons: dict[str, Gtk.RadioButton] = {}
46
+ _theme_buttons: dict[str, Gtk.RadioButton] = {}
47
+ _debounce_sources: dict[str, int] = {}
48
+ _last_state: dict | None = None
49
+ _ui_syncing = False
50
+ _last_user_interaction: float = 0.0
51
+ INTERACTION_COOLDOWN = 3.0 # seconds — skip periodic slider updates while user interacts
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Helpers
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def _load_state() -> dict | None:
59
+ if not STATE_PATH.exists():
60
+ return None
61
+ try:
62
+ with STATE_PATH.open(encoding="utf-8") as fh:
63
+ data = json.load(fh)
64
+ return data if isinstance(data, dict) else None
65
+ except (OSError, json.JSONDecodeError):
66
+ return None
67
+
68
+
69
+ def _extract_state(payload: dict | None) -> dict | None:
70
+ if not isinstance(payload, dict):
71
+ return None
72
+ state = payload.get("state", payload)
73
+ return state if isinstance(state, dict) else None
74
+
75
+
76
+ def _send_socket_command(command: dict) -> dict:
77
+ try:
78
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
79
+ sock.settimeout(SOCKET_TIMEOUT_SECONDS)
80
+ try:
81
+ sock.connect(str(SOCKET_PATH))
82
+ sock.sendall(json.dumps(command).encode() + b"\n")
83
+ sock.shutdown(socket.SHUT_WR)
84
+ response = b""
85
+ while True:
86
+ chunk = sock.recv(4096)
87
+ if not chunk:
88
+ break
89
+ response += chunk
90
+ finally:
91
+ sock.close()
92
+ except OSError as exc:
93
+ return {"ok": False, "error": f"daemon not reachable: {exc}"}
94
+
95
+ if not response:
96
+ return {"ok": False, "error": "daemon returned empty response"}
97
+
98
+ try:
99
+ return json.loads(response.decode())
100
+ except json.JSONDecodeError as exc:
101
+ return {"ok": False, "error": f"invalid daemon response: {exc}"}
102
+
103
+
104
+ def _schedule_command(key: str, callback, delay_ms: int = SLIDER_DEBOUNCE_MS) -> None:
105
+ source_id = _debounce_sources.pop(key, None)
106
+ if source_id:
107
+ GLib.source_remove(source_id)
108
+
109
+ def _fire() -> bool:
110
+ _debounce_sources.pop(key, None)
111
+ callback()
112
+ return False
113
+
114
+ _debounce_sources[key] = GLib.timeout_add(delay_ms, _fire)
115
+
116
+
117
+ def _run_command_async(commands: list[dict] | dict, pending_text: str) -> None:
118
+ _set_status_text(pending_text, "busy")
119
+ command_list = commands if isinstance(commands, list) else [commands]
120
+
121
+ def worker() -> None:
122
+ last_result = {"ok": True}
123
+ for command in command_list:
124
+ last_result = _send_socket_command(command)
125
+ if not last_result.get("ok", True):
126
+ GLib.idle_add(_handle_command_result, None, last_result)
127
+ return
128
+
129
+ GLib.idle_add(_handle_command_result, _load_state(), last_result)
130
+
131
+ threading.Thread(
132
+ target=worker,
133
+ daemon=True,
134
+ name="claude-panel-sonnenuhr-command",
135
+ ).start()
136
+
137
+
138
+ def _refresh_from_daemon_async(_button=None) -> None:
139
+ _set_status_text("Status wird geladen…", "busy")
140
+
141
+ def worker() -> None:
142
+ result = _send_socket_command({"cmd": "status"})
143
+ if result.get("ok", True) and "message" not in result:
144
+ result = dict(result)
145
+ result["message"] = "Status aktualisiert"
146
+ state = _extract_state(result) if result.get("ok", True) else None
147
+ if state is None:
148
+ state = _load_state()
149
+ GLib.idle_add(_handle_command_result, state, result)
150
+
151
+ threading.Thread(
152
+ target=worker,
153
+ daemon=True,
154
+ name="claude-panel-sonnenuhr-refresh",
155
+ ).start()
156
+
157
+
158
+ def _handle_command_result(state: dict | None, result: dict) -> bool:
159
+ global _last_user_interaction
160
+ if result and not result.get("ok", True):
161
+ _set_status_text(result.get("error", "Unbekannter Fehler"), "error")
162
+ return False
163
+
164
+ # Clear cooldown — command response has fresh state from daemon
165
+ _last_user_interaction = 0.0
166
+ if state is not None:
167
+ _apply_state_to_ui(state, force=True)
168
+ else:
169
+ _apply_state_to_ui(_load_state(), force=True)
170
+
171
+ message = result.get("message") or "Einstellung übernommen"
172
+ _set_status_text(message, "ok")
173
+ return False
174
+
175
+
176
+ def _set_status_text(text: str, tone: str = "dim") -> None:
177
+ if _footer_status is None:
178
+ return
179
+ palette = get_palette()
180
+ color = {
181
+ "ok": palette["green"],
182
+ "error": palette["red"],
183
+ "busy": palette["yellow"],
184
+ "dim": palette["dim"],
185
+ }.get(tone, palette["dim"])
186
+ escaped = GLib.markup_escape_text(text)
187
+ _footer_status.set_markup(
188
+ f'<span foreground="{color}" size="small">{escaped}</span>'
189
+ )
190
+
191
+
192
+ def _fmt(iso: str | None, fmt: str = "%H:%M") -> str:
193
+ if not iso:
194
+ return "—"
195
+ try:
196
+ return datetime.fromisoformat(iso).strftime(fmt)
197
+ except (TypeError, ValueError):
198
+ return "—"
199
+
200
+
201
+ def _day_progress(state: dict) -> float:
202
+ try:
203
+ sun = state["sun_times"]
204
+ sunrise = datetime.fromisoformat(sun["sunrise"])
205
+ sunset = datetime.fromisoformat(sun["sunset"])
206
+ now = datetime.now(tz=sunrise.tzinfo)
207
+ if now <= sunrise:
208
+ return 0.0
209
+ if now >= sunset:
210
+ return 1.0
211
+ return (now - sunrise).total_seconds() / (sunset - sunrise).total_seconds()
212
+ except Exception:
213
+ return 0.0
214
+
215
+
216
+ def _section(title: str) -> Gtk.Box:
217
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
218
+ box.get_style_context().add_class("section-frame")
219
+ box.set_margin_bottom(10)
220
+ label = Gtk.Label(label=title)
221
+ label.get_style_context().add_class("section-title")
222
+ label.set_halign(Gtk.Align.START)
223
+ box.pack_start(label, False, False, 0)
224
+ return box
225
+
226
+
227
+ def _lbl(markup: str, halign: Gtk.Align = Gtk.Align.START) -> Gtk.Label:
228
+ label = Gtk.Label()
229
+ label.set_markup(markup)
230
+ label.set_halign(halign)
231
+ label.set_xalign(0.0 if halign != Gtk.Align.END else 1.0)
232
+ return label
233
+
234
+
235
+ def _build_radio_row(options: list[tuple[str, str]], callback) -> tuple[Gtk.Box, dict[str, Gtk.RadioButton]]:
236
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
237
+ buttons: dict[str, Gtk.RadioButton] = {}
238
+ group: Gtk.RadioButton | None = None
239
+
240
+ for key, label in options:
241
+ button = Gtk.RadioButton.new_with_label_from_widget(group, label)
242
+ button.set_can_focus(False)
243
+ button.connect("toggled", callback, key)
244
+ row.pack_start(button, False, False, 0)
245
+ buttons[key] = button
246
+ if group is None:
247
+ group = button
248
+
249
+ return row, buttons
250
+
251
+
252
+ def _set_fixed_controls_sensitive(enabled: bool) -> None:
253
+ if _refresh_button is not None:
254
+ _refresh_button.set_sensitive(True)
255
+ for button in _mode_buttons.values():
256
+ button.set_sensitive(enabled)
257
+ for button in _theme_buttons.values():
258
+ button.set_sensitive(enabled)
259
+ if _brightness_scale is not None:
260
+ _brightness_scale.set_sensitive(enabled)
261
+ if _temp_scale is not None:
262
+ _temp_scale.set_sensitive(enabled)
263
+
264
+
265
+ def _set_brightness_value_markup(value: int) -> None:
266
+ if _brightness_value is None:
267
+ return
268
+ palette = get_palette()
269
+ _brightness_value.set_markup(
270
+ f'<span foreground="{palette["peach"]}" font_size="22000" font_weight="bold">'
271
+ f"{value}%</span>"
272
+ )
273
+
274
+
275
+ def _set_temp_value_markup(value: int) -> None:
276
+ if _temp_value is None:
277
+ return
278
+ palette = get_palette()
279
+ _temp_value.set_markup(
280
+ f'<span foreground="{palette["yellow"]}" font_size="22000" font_weight="bold">'
281
+ f"{value} K</span>"
282
+ )
283
+
284
+
285
+ def _on_mode_toggled(button: Gtk.RadioButton, mode: str) -> None:
286
+ global _last_user_interaction
287
+ if _ui_syncing or not button.get_active():
288
+ return
289
+ _last_user_interaction = time.monotonic()
290
+
291
+ if mode == "auto":
292
+ command = {"cmd": "auto"}
293
+ elif mode == "disabled":
294
+ command = {"cmd": "disable"}
295
+ else:
296
+ minutes = int((_last_state or {}).get("override_duration_minutes", 120))
297
+ command = {"cmd": "override", "minutes": minutes}
298
+
299
+ _run_command_async(command, "Modus wird gesetzt…")
300
+
301
+
302
+ def _on_theme_toggled(button: Gtk.RadioButton, mode: str) -> None:
303
+ global _last_user_interaction
304
+ if _ui_syncing or not button.get_active():
305
+ return
306
+ _last_user_interaction = time.monotonic()
307
+ _run_command_async({"cmd": "theme", "mode": mode}, "Theme wird gesetzt…")
308
+
309
+
310
+ def _on_global_brightness_changed(scale: Gtk.Scale) -> None:
311
+ global _last_user_interaction
312
+ if _ui_syncing:
313
+ return
314
+ _last_user_interaction = time.monotonic()
315
+
316
+ value = int(round(scale.get_value()))
317
+ _set_brightness_value_markup(value)
318
+ _schedule_command(
319
+ "global_brightness",
320
+ lambda: _run_command_async(
321
+ {"cmd": "set", "brightness": value},
322
+ "Globale Helligkeit wird gesetzt…",
323
+ ),
324
+ )
325
+
326
+
327
+ def _on_global_temp_changed(scale: Gtk.Scale) -> None:
328
+ global _last_user_interaction
329
+ if _ui_syncing:
330
+ return
331
+ _last_user_interaction = time.monotonic()
332
+
333
+ value = int(round(scale.get_value() / 50.0) * 50)
334
+ _set_temp_value_markup(value)
335
+ _schedule_command(
336
+ "global_temp",
337
+ lambda: _run_command_async(
338
+ {"cmd": "color", "kelvin": value},
339
+ "Globale Farbtemperatur wird gesetzt…",
340
+ ),
341
+ )
342
+
343
+
344
+ def _on_display_brightness_changed(
345
+ scale: Gtk.Scale,
346
+ display_name: str,
347
+ value_label: Gtk.Label,
348
+ ) -> None:
349
+ global _last_user_interaction
350
+ _last_user_interaction = time.monotonic()
351
+ value = int(round(scale.get_value()))
352
+ palette = get_palette()
353
+ value_label.set_markup(
354
+ f'<tt><span foreground="{palette["peach"]}" size="small">{value}%</span></tt>'
355
+ )
356
+ _schedule_command(
357
+ f"display-brightness:{display_name}",
358
+ lambda: _run_command_async(
359
+ {"cmd": "set_display", "display": display_name, "brightness": value},
360
+ f"{display_name} wird angepasst…",
361
+ ),
362
+ )
363
+
364
+
365
+ def _on_display_offset_changed(
366
+ scale: Gtk.Scale,
367
+ display_name: str,
368
+ value_label: Gtk.Label,
369
+ ) -> None:
370
+ global _last_user_interaction
371
+ _last_user_interaction = time.monotonic()
372
+ value = int(round(scale.get_value()))
373
+ palette = get_palette()
374
+ value_label.set_markup(
375
+ f'<tt><span foreground="{palette["overlay"]}" size="small">{value:+d}</span></tt>'
376
+ )
377
+ _schedule_command(
378
+ f"display-offset:{display_name}",
379
+ lambda: _run_command_async(
380
+ {"cmd": "set_display_offset", "display": display_name, "offset": value},
381
+ f"{display_name} Korrektur wird gespeichert…",
382
+ ),
383
+ delay_ms=500,
384
+ )
385
+
386
+
387
+ def _on_display_enabled_changed(
388
+ switch: Gtk.Switch,
389
+ _pspec,
390
+ display_name: str,
391
+ ) -> None:
392
+ global _last_user_interaction
393
+ _last_user_interaction = time.monotonic()
394
+ _run_command_async(
395
+ {
396
+ "cmd": "set_display_enabled",
397
+ "display": display_name,
398
+ "enabled": switch.get_active(),
399
+ },
400
+ f"{display_name} wird aktualisiert…",
401
+ )
402
+
403
+
404
+ def _build_display_row(display: dict, palette: dict, base_brightness: int) -> Gtk.Box:
405
+ card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
406
+ card.get_style_context().add_class("section-frame")
407
+ card.set_margin_bottom(8)
408
+ card.set_margin_top(2)
409
+ card.set_margin_start(4)
410
+ card.set_margin_end(4)
411
+
412
+ display_name = display.get("name", "unknown")
413
+ connected = bool(display.get("connected", False))
414
+ enabled = bool(display.get("enabled", True))
415
+ brightness = display.get("brightness")
416
+ brightness = int(brightness) if isinstance(brightness, int) else base_brightness
417
+ offset = int(display.get("brightness_offset", 0))
418
+ rgb = display.get("rgb")
419
+
420
+ top = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
421
+ dot_color = palette["green"] if (connected and enabled) else (
422
+ palette["yellow"] if connected else palette["red"]
423
+ )
424
+ dot = "✓" if (connected and enabled) else ("○" if connected else "✗")
425
+ top.pack_start(
426
+ _lbl(f'<span foreground="{dot_color}">{dot}</span>'),
427
+ False,
428
+ False,
429
+ 0,
430
+ )
431
+
432
+ name = _lbl(
433
+ f'<tt><span foreground="{palette["subtext1"]}" size="small">'
434
+ f"{GLib.markup_escape_text(display_name)}</span></tt>"
435
+ )
436
+ name.set_ellipsize(Pango.EllipsizeMode.MIDDLE)
437
+ name.set_max_width_chars(38)
438
+ top.pack_start(name, True, True, 0)
439
+
440
+ connected_text = "verbunden" if connected else "getrennt"
441
+ top.pack_end(
442
+ _lbl(
443
+ f'<span foreground="{dot_color}" size="small">{connected_text}</span>',
444
+ Gtk.Align.END,
445
+ ),
446
+ False,
447
+ False,
448
+ 0,
449
+ )
450
+ card.pack_start(top, False, False, 0)
451
+
452
+ brightness_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
453
+ brightness_header.pack_start(
454
+ _lbl(f'<span foreground="{palette["overlay"]}" size="small">Monitor-Helligkeit</span>'),
455
+ False,
456
+ False,
457
+ 0,
458
+ )
459
+ brightness_value = _lbl(
460
+ f'<tt><span foreground="{palette["peach"]}" size="small">{brightness}%</span></tt>',
461
+ Gtk.Align.END,
462
+ )
463
+ brightness_header.pack_end(brightness_value, False, False, 0)
464
+ card.pack_start(brightness_header, False, False, 0)
465
+
466
+ brightness_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1)
467
+ brightness_scale.set_draw_value(False)
468
+ brightness_scale.set_value(float(brightness))
469
+ brightness_scale.connect(
470
+ "value-changed",
471
+ _on_display_brightness_changed,
472
+ display_name,
473
+ brightness_value,
474
+ )
475
+ card.pack_start(brightness_scale, False, False, 0)
476
+
477
+ offset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
478
+ offset_row.pack_start(
479
+ _lbl(f'<span foreground="{palette["overlay"]}" size="small">Offset</span>'),
480
+ False,
481
+ False,
482
+ 0,
483
+ )
484
+ offset_value = _lbl(
485
+ f'<tt><span foreground="{palette["overlay"]}" size="small">{offset:+d}</span></tt>'
486
+ )
487
+ offset_row.pack_end(offset_value, False, False, 0)
488
+ card.pack_start(offset_row, False, False, 0)
489
+
490
+ offset_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, OFFSET_MIN, OFFSET_MAX, 1)
491
+ offset_scale.set_draw_value(False)
492
+ offset_scale.set_value(float(offset))
493
+ offset_scale.connect(
494
+ "value-changed",
495
+ _on_display_offset_changed,
496
+ display_name,
497
+ offset_value,
498
+ )
499
+ card.pack_start(offset_scale, False, False, 0)
500
+
501
+ footer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
502
+ enabled_label = _lbl(
503
+ f'<span foreground="{palette["overlay"]}" size="small">aktiv</span>'
504
+ )
505
+ enabled_switch = Gtk.Switch()
506
+ enabled_switch.set_active(enabled)
507
+ enabled_switch.connect("notify::active", _on_display_enabled_changed, display_name)
508
+ footer.pack_start(enabled_label, False, False, 0)
509
+ footer.pack_start(enabled_switch, False, False, 0)
510
+
511
+ if rgb is not None and len(rgb) == 3:
512
+ footer.pack_end(
513
+ _lbl(
514
+ f'<tt><span foreground="{palette["dim"]}" size="small">'
515
+ f"RGB {rgb[0]}/{rgb[1]}/{rgb[2]}</span></tt>",
516
+ Gtk.Align.END,
517
+ ),
518
+ False,
519
+ False,
520
+ 0,
521
+ )
522
+
523
+ card.pack_start(footer, False, False, 0)
524
+ return card
525
+
526
+
527
+ def _apply_state_to_ui(state: dict | None, force: bool = False) -> None:
528
+ global _last_state, _ui_syncing
529
+
530
+ palette = get_palette()
531
+ offline = state is None
532
+ # Skip slider/display updates during periodic refresh if user recently interacted.
533
+ # Command-response refreshes pass force=True and always update.
534
+ skip_controls = not force and (time.monotonic() - _last_user_interaction) < INTERACTION_COOLDOWN
535
+ if not offline:
536
+ _last_state = state
537
+
538
+ if _header_label is not None:
539
+ _header_label.set_markup("<b>☀ Sonnenuhr</b>")
540
+
541
+ if offline:
542
+ if _mode_badge is not None:
543
+ _mode_badge.set_markup(
544
+ f'<span foreground="{palette["red"]}" size="small">● offline</span>'
545
+ )
546
+ if _timeline_label is not None:
547
+ _timeline_label.set_markup(
548
+ f'<span foreground="{palette["red"]}" size="small">Daemon nicht aktiv</span>'
549
+ )
550
+ if _timeline_bar is not None:
551
+ _timeline_bar.set_fraction(0.0)
552
+ _timeline_bar.set_text("—")
553
+ _set_brightness_value_markup(0)
554
+ _set_temp_value_markup(0)
555
+ if _temp_rgb is not None:
556
+ _temp_rgb.set_markup(f'<span foreground="{palette["dim"]}" size="small">—</span>')
557
+ if _footer_update is not None:
558
+ _footer_update.set_markup(
559
+ f'<span foreground="{palette["dim"]}" size="small">Letzte Aktualisierung: —</span>'
560
+ )
561
+ if _footer_theme is not None:
562
+ _footer_theme.set_markup(
563
+ f'<span foreground="{palette["dim"]}" size="small">Theme: —</span>'
564
+ )
565
+ if _displays_box is not None:
566
+ for child in _displays_box.get_children():
567
+ _displays_box.remove(child)
568
+ _displays_box.pack_start(
569
+ _lbl(f'<span foreground="{palette["dim"]}">Keine Displays</span>'),
570
+ False,
571
+ False,
572
+ 0,
573
+ )
574
+ _displays_box.show_all()
575
+ _set_fixed_controls_sensitive(False)
576
+ return
577
+
578
+ mode = state.get("mode", "unknown")
579
+ brightness = int(state.get("brightness", 0))
580
+ color_temp = int(state.get("color_temp", 6500))
581
+ sun = state.get("sun_times", {})
582
+ is_daytime = bool(state.get("is_daytime", False))
583
+ displays = state.get("displays", [])
584
+ theme_name = state.get("theme", "—")
585
+ theme_locked = bool(state.get("theme_locked", False))
586
+ theme_auto_switch = bool(state.get("theme_auto_switch", True))
587
+ override_minutes = int(state.get("override_duration_minutes", 120))
588
+
589
+ if _mode_badge is not None:
590
+ badge_color = {
591
+ "auto": palette["green"],
592
+ "override": palette["yellow"],
593
+ "disabled": palette["red"],
594
+ }.get(mode, palette["red"])
595
+ _mode_badge.set_markup(
596
+ f'<span foreground="{badge_color}" size="small">● {mode}</span>'
597
+ )
598
+
599
+ if _timeline_label is not None:
600
+ day_text = "☀ Tag" if is_daytime else "☾ Nacht"
601
+ _timeline_label.set_markup(
602
+ f'<span foreground="{palette["overlay"]}" size="small">'
603
+ f"↑ {_fmt(sun.get('sunrise'))} {day_text} ↓ {_fmt(sun.get('sunset'))}</span>"
604
+ )
605
+ if _timeline_bar is not None:
606
+ _timeline_bar.set_fraction(_day_progress(state))
607
+ _timeline_bar.set_text(f"{_fmt(sun.get('sunrise'))} → {_fmt(sun.get('sunset'))}")
608
+
609
+ _set_brightness_value_markup(brightness)
610
+ _set_temp_value_markup(color_temp)
611
+
612
+ rgb = next((d.get("rgb") for d in displays if d.get("rgb") is not None), None)
613
+ if _temp_rgb is not None:
614
+ rgb_text = f"RGB: {rgb[0]} / {rgb[1]} / {rgb[2]}" if rgb else "—"
615
+ _temp_rgb.set_markup(
616
+ f'<span foreground="{palette["overlay"]}" size="small">{rgb_text}</span>'
617
+ )
618
+
619
+ _ui_syncing = True
620
+ try:
621
+ _set_fixed_controls_sensitive(True)
622
+ if not skip_controls:
623
+ if _brightness_scale is not None:
624
+ _brightness_scale.set_value(float(brightness))
625
+ if _temp_scale is not None:
626
+ _temp_scale.set_value(float(color_temp))
627
+
628
+ for key, button in _mode_buttons.items():
629
+ button.set_active(key == mode)
630
+
631
+ selected_theme = "auto" if not theme_locked else theme_name
632
+ for key, button in _theme_buttons.items():
633
+ button.set_active(key == selected_theme)
634
+ finally:
635
+ _ui_syncing = False
636
+
637
+ if _displays_box is not None and not skip_controls:
638
+ for child in _displays_box.get_children():
639
+ _displays_box.remove(child)
640
+ if displays:
641
+ for display in displays:
642
+ _displays_box.pack_start(
643
+ _build_display_row(display, palette, brightness),
644
+ False,
645
+ False,
646
+ 0,
647
+ )
648
+ else:
649
+ _displays_box.pack_start(
650
+ _lbl(f'<span foreground="{palette["dim"]}">Keine Displays gefunden</span>'),
651
+ False,
652
+ False,
653
+ 0,
654
+ )
655
+ _displays_box.show_all()
656
+
657
+ if _footer_update is not None:
658
+ _footer_update.set_markup(
659
+ f'<span foreground="{palette["dim"]}" size="small">'
660
+ f"Letzte Aktualisierung: {_fmt(state.get('last_update'), '%H:%M:%S')}</span>"
661
+ )
662
+ if _footer_theme is not None:
663
+ theme_mode = "gesperrt" if theme_locked else "auto"
664
+ suffix = "" if theme_locked or theme_auto_switch else ", Wechsel aus"
665
+ _footer_theme.set_markup(
666
+ f'<span foreground="{palette["dim"]}" size="small">'
667
+ f"Theme: {theme_name} ({theme_mode}{suffix}) · Override: {override_minutes} min</span>"
668
+ )
669
+
670
+
671
+ # ---------------------------------------------------------------------------
672
+ # Refresh
673
+ # ---------------------------------------------------------------------------
674
+
675
+ def _do_refresh() -> bool:
676
+ _apply_state_to_ui(_load_state())
677
+ return False
678
+
679
+
680
+ def refresh_brightness(_tab=None) -> bool:
681
+ GLib.idle_add(_do_refresh)
682
+ return True
683
+
684
+
685
+ def build_brightness_tab() -> Gtk.ScrolledWindow:
686
+ global _header_label, _mode_badge, _refresh_button, _timeline_bar, _timeline_label
687
+ global _brightness_value, _brightness_scale, _temp_value, _temp_scale, _temp_rgb
688
+ global _displays_box, _footer_update, _footer_theme, _footer_status
689
+ global _mode_buttons, _theme_buttons
690
+
691
+ scrolled = Gtk.ScrolledWindow()
692
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
693
+ scrolled.set_hexpand(True)
694
+ scrolled.set_vexpand(True)
695
+
696
+ root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
697
+ root.set_margin_top(10)
698
+ root.set_margin_bottom(10)
699
+ root.set_margin_start(10)
700
+ root.set_margin_end(10)
701
+ scrolled.add(root)
702
+
703
+ palette = get_palette()
704
+
705
+ header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
706
+ header.set_margin_bottom(8)
707
+ _header_label = _lbl("<b>☀ Sonnenuhr</b>")
708
+ attrs = Pango.AttrList()
709
+ attrs.insert(Pango.attr_scale_new(1.1))
710
+ _header_label.set_attributes(attrs)
711
+ header.pack_start(_header_label, True, True, 0)
712
+
713
+ _mode_badge = _lbl(
714
+ f'<span foreground="{palette["dim"]}" size="small">● lade…</span>',
715
+ Gtk.Align.END,
716
+ )
717
+ header.pack_end(_mode_badge, False, False, 0)
718
+
719
+ _refresh_button = Gtk.Button(label="Aktualisieren")
720
+ _refresh_button.set_can_focus(False)
721
+ _refresh_button.connect("clicked", _refresh_from_daemon_async)
722
+ header.pack_end(_refresh_button, False, False, 0)
723
+ root.pack_start(header, False, False, 0)
724
+
725
+ timeline = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
726
+ timeline.get_style_context().add_class("section-frame")
727
+ timeline.set_margin_bottom(10)
728
+ _timeline_label = _lbl(
729
+ f'<span foreground="{palette["overlay"]}" size="small">Lade…</span>',
730
+ Gtk.Align.CENTER,
731
+ )
732
+ timeline.pack_start(_timeline_label, False, False, 0)
733
+ _timeline_bar = Gtk.ProgressBar()
734
+ _timeline_bar.set_show_text(True)
735
+ _timeline_bar.set_fraction(0.0)
736
+ _timeline_bar.set_text("—")
737
+ timeline.pack_start(_timeline_bar, False, False, 0)
738
+ root.pack_start(timeline, False, False, 0)
739
+
740
+ mode_section = _section("Modus")
741
+ mode_row, _mode_buttons = _build_radio_row(
742
+ [("auto", "Auto"), ("override", "Override"), ("disabled", "Disabled")],
743
+ _on_mode_toggled,
744
+ )
745
+ mode_section.pack_start(mode_row, False, False, 0)
746
+ root.pack_start(mode_section, False, False, 0)
747
+
748
+ theme_section = _section("Theme")
749
+ theme_row, _theme_buttons = _build_radio_row(
750
+ [("dark", "Dark"), ("light", "Light"), ("auto", "Auto")],
751
+ _on_theme_toggled,
752
+ )
753
+ theme_section.pack_start(theme_row, False, False, 0)
754
+ root.pack_start(theme_section, False, False, 0)
755
+
756
+ brightness_section = _section("Globale Helligkeit")
757
+ _brightness_value = _lbl(
758
+ f'<span foreground="{palette["peach"]}" font_size="22000" font_weight="bold">—</span>'
759
+ )
760
+ brightness_section.pack_start(_brightness_value, False, False, 0)
761
+ _brightness_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0, 100, 1)
762
+ _brightness_scale.set_draw_value(False)
763
+ _brightness_scale.set_value(0)
764
+ _brightness_scale.connect("value-changed", _on_global_brightness_changed)
765
+ brightness_section.pack_start(_brightness_scale, False, False, 0)
766
+ root.pack_start(brightness_section, False, False, 0)
767
+
768
+ temp_section = _section("Globale Farbtemperatur")
769
+ _temp_value = _lbl(
770
+ f'<span foreground="{palette["yellow"]}" font_size="22000" font_weight="bold">—</span>'
771
+ )
772
+ temp_section.pack_start(_temp_value, False, False, 0)
773
+ _temp_scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 2700, 6500, 50)
774
+ _temp_scale.set_draw_value(False)
775
+ _temp_scale.set_value(6500)
776
+ _temp_scale.connect("value-changed", _on_global_temp_changed)
777
+ temp_section.pack_start(_temp_scale, False, False, 0)
778
+ _temp_rgb = _lbl(f'<span foreground="{palette["overlay"]}" size="small">—</span>')
779
+ temp_section.pack_start(_temp_rgb, False, False, 0)
780
+ root.pack_start(temp_section, False, False, 0)
781
+
782
+ displays_section = _section("Displays")
783
+ _displays_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
784
+ displays_section.pack_start(_displays_box, False, False, 0)
785
+ root.pack_start(displays_section, False, False, 0)
786
+
787
+ separator = Gtk.Separator()
788
+ separator.set_margin_top(4)
789
+ separator.set_margin_bottom(6)
790
+ root.pack_start(separator, False, False, 0)
791
+
792
+ _footer_update = _lbl(
793
+ f'<span foreground="{palette["dim"]}" size="small">Letzte Aktualisierung: —</span>'
794
+ )
795
+ root.pack_start(_footer_update, False, False, 0)
796
+ _footer_theme = _lbl(
797
+ f'<span foreground="{palette["dim"]}" size="small">Theme: —</span>'
798
+ )
799
+ root.pack_start(_footer_theme, False, False, 0)
800
+ _footer_status = _lbl(
801
+ f'<span foreground="{palette["dim"]}" size="small">Bereit</span>'
802
+ )
803
+ root.pack_start(_footer_status, False, False, 0)
804
+
805
+ GLib.idle_add(_do_refresh)
806
+ scrolled.show_all()
807
+ return scrolled
claude-panel-icon.png ADDED
claude-panel.desktop ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [Desktop Entry]
2
+ Name=Claude Code Panel
3
+ Comment=Control Panel fuer Claude Code — Settings, Hooks, Sessions, Skills
4
+ Exec=python3 /home/smlflg/Projekte/ClaudeCodePanel/panel.py
5
+ Icon=preferences-system
6
+ Terminal=false
7
+ Type=Application
8
+ Categories=Development;Settings;
9
+ Keywords=claude;code;settings;panel;ai;
10
+ StartupWMClass=claude-code-panel
claude-panel.service ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Unit]
2
+ Description=Claude Code Control Panel (GTK3 + Tray Icon)
3
+ After=graphical-session.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ ExecStart=/usr/bin/python3 /home/smlflg/Projekte/ClaudeCodePanel/panel.py
8
+ Restart=on-failure
9
+ RestartSec=5
10
+ StartLimitIntervalSec=60
11
+ StartLimitBurst=5
12
+
13
+ [Install]
14
+ WantedBy=default.target
config_io.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Config I/O for Claude Code Control Panel.
2
+
3
+ Reads and writes ~/.claude/settings.json with atomic operations (backup + tmp + rename).
4
+ Also handles hook configuration and coaching script parameters.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
15
+ COACH_SCRIPT = Path.home() / ".claude" / "hooks" / "coaching" / "coach-git-test.sh"
16
+
17
+
18
+ def read_settings() -> dict:
19
+ """Read and parse ~/.claude/settings.json."""
20
+ if not SETTINGS_PATH.exists():
21
+ return {}
22
+ with SETTINGS_PATH.open() as f:
23
+ return json.load(f)
24
+
25
+
26
+ def write_settings(settings: dict) -> None:
27
+ """Atomically write settings.json (backup + tmp + rename)."""
28
+ # Create backup
29
+ if SETTINGS_PATH.exists():
30
+ backup = SETTINGS_PATH.with_suffix(
31
+ f".json.backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
32
+ )
33
+ shutil.copy2(SETTINGS_PATH, backup)
34
+
35
+ # Write to temp file, then atomic rename
36
+ fd, tmp_path = tempfile.mkstemp(
37
+ dir=SETTINGS_PATH.parent, suffix=".tmp", prefix="settings-"
38
+ )
39
+ try:
40
+ with os.fdopen(fd, "w") as f:
41
+ json.dump(settings, f, indent=2, ensure_ascii=False)
42
+ f.write("\n")
43
+ os.replace(tmp_path, SETTINGS_PATH)
44
+ except Exception:
45
+ if os.path.exists(tmp_path):
46
+ os.unlink(tmp_path)
47
+ raise
48
+
49
+
50
+ def update_setting(key_path: str, value) -> dict:
51
+ """Update a nested key in settings.json using dot notation.
52
+
53
+ Example: update_setting("env.SLASH_COMMAND_TOOL_CHAR_BUDGET", "15000")
54
+ """
55
+ settings = read_settings()
56
+ keys = key_path.split(".")
57
+ obj = settings
58
+ for key in keys[:-1]:
59
+ if key not in obj:
60
+ obj[key] = {}
61
+ obj = obj[key]
62
+ obj[keys[-1]] = value
63
+ write_settings(settings)
64
+ return settings
65
+
66
+
67
+ def read_hook_list() -> list[dict]:
68
+ """Extract all hooks from settings.json as a flat list.
69
+
70
+ Returns list of dicts with:
71
+ event, group_index, matcher, command, timeout, async_, short_name
72
+ """
73
+ settings = read_settings()
74
+ hooks_config = settings.get("hooks", {})
75
+ result = []
76
+
77
+ for event_name, hook_groups in hooks_config.items():
78
+ for group_idx, group in enumerate(hook_groups):
79
+ matcher = group.get("matcher", "")
80
+ for hook in group.get("hooks", []):
81
+ command = hook.get("command", "")
82
+ short_name = _derive_hook_name(command)
83
+ result.append({
84
+ "event": event_name,
85
+ "group_index": group_idx,
86
+ "matcher": matcher,
87
+ "command": command,
88
+ "timeout": hook.get("timeout", 10),
89
+ "async_": hook.get("async", False),
90
+ "short_name": short_name,
91
+ })
92
+ return result
93
+
94
+
95
+ def _derive_hook_name(command: str) -> str:
96
+ """Extract a readable name from a hook command path."""
97
+ parts = command.strip().split()
98
+ for part in reversed(parts):
99
+ if "/" in part:
100
+ name = Path(part).stem
101
+ return name.replace("_", " ").replace("-", " ").title()
102
+ return command[:30]
103
+
104
+
105
+ def update_hook(event: str, group_index: int, field: str, value) -> dict:
106
+ """Update a specific hook parameter in settings.json.
107
+
108
+ field can be: 'timeout', 'async'
109
+ """
110
+ settings = read_settings()
111
+ hooks_config = settings.get("hooks", {})
112
+
113
+ if event not in hooks_config:
114
+ raise KeyError(f"Event '{event}' not found in hooks config")
115
+ if group_index >= len(hooks_config[event]):
116
+ raise IndexError(f"Group index {group_index} out of range")
117
+
118
+ group = hooks_config[event][group_index]
119
+ for hook in group.get("hooks", []):
120
+ if field == "timeout":
121
+ hook["timeout"] = value
122
+ elif field == "async":
123
+ if value:
124
+ hook["async"] = True
125
+ elif "async" in hook:
126
+ del hook["async"]
127
+
128
+ write_settings(settings)
129
+ return settings
130
+
131
+
132
+ def read_coaching_rate_limit() -> int:
133
+ """Read RATE_LIMIT_SECONDS from coach-git-test.sh."""
134
+ if not COACH_SCRIPT.exists():
135
+ return 300
136
+ with COACH_SCRIPT.open() as f:
137
+ for line in f:
138
+ if line.strip().startswith("RATE_LIMIT_SECONDS="):
139
+ try:
140
+ return int(line.split("=", 1)[1].strip().split("#")[0].strip())
141
+ except ValueError:
142
+ return 300
143
+ return 300
144
+
145
+
146
+ def write_coaching_rate_limit(seconds: int) -> None:
147
+ """Update RATE_LIMIT_SECONDS in coach-git-test.sh."""
148
+ if not COACH_SCRIPT.exists():
149
+ return
150
+
151
+ lines = COACH_SCRIPT.read_text().splitlines(keepends=True)
152
+ new_lines = []
153
+ for line in lines:
154
+ if line.strip().startswith("RATE_LIMIT_SECONDS="):
155
+ new_lines.append(f"RATE_LIMIT_SECONDS={seconds} # {seconds // 60} minutes\n")
156
+ else:
157
+ new_lines.append(line)
158
+
159
+ # Atomic write
160
+ fd, tmp = tempfile.mkstemp(dir=COACH_SCRIPT.parent, suffix=".tmp")
161
+ try:
162
+ with os.fdopen(fd, "w") as f:
163
+ f.writelines(new_lines)
164
+ os.replace(tmp, COACH_SCRIPT)
165
+ except Exception:
166
+ if os.path.exists(tmp):
167
+ os.unlink(tmp)
168
+ raise
event_tab.py ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Event tab widget for Claude Code Control Panel.
2
+
3
+ Live hook event viewer with filtering and auto-scroll.
4
+ Reads from ~/.claude/events/YYYY-MM-DD.jsonl (day-file tracking).
5
+ Auto-refreshes via external timer calling refresh_events().
6
+ """
7
+
8
+ import json
9
+ from datetime import date, datetime
10
+ from pathlib import Path
11
+
12
+ import gi
13
+ gi.require_version("Gtk", "3.0")
14
+ from gi.repository import Gtk, GLib, Pango
15
+
16
+ from theme import get_palette
17
+ from utils import idle_once
18
+
19
+ EVENTS_DIR = Path.home() / ".claude" / "events"
20
+
21
+ _MAX_ROWS = 500
22
+
23
+ # Module-level widget references
24
+ _list_box = None
25
+ _filter_combo = None
26
+ _search_entry = None
27
+ _auto_scroll_btn = None
28
+ _scrolled_window = None
29
+ _stats_label = None
30
+
31
+ # State
32
+ _rows = [] # list of parsed event dicts (most recent last)
33
+ _file_offset = 0 # byte offset into current day file
34
+ _current_day = None # date string "YYYY-MM-DD" of last read
35
+
36
+ # All known hook event types (for filter combo population)
37
+ _KNOWN_TYPES = [
38
+ "PreToolUse",
39
+ "PostToolUse",
40
+ "PostToolUseFailure",
41
+ "UserPromptSubmit",
42
+ "SubagentStart",
43
+ "SubagentStop",
44
+ "SessionStart",
45
+ "SessionEnd",
46
+ "PreCompact",
47
+ "Notification",
48
+ "PermissionRequest",
49
+ "Stop",
50
+ ]
51
+
52
+
53
+ def _type_color(event_type: str) -> str:
54
+ """Return palette hex color for a given event type badge."""
55
+ p = get_palette()
56
+ mapping = {
57
+ "PreToolUse": p["accent"],
58
+ "PostToolUse": p["green"],
59
+ "PostToolUseFailure": p["red"],
60
+ "UserPromptSubmit": p["mauve"],
61
+ "SubagentStart": p["peach"],
62
+ "SubagentStop": p["yellow"],
63
+ "SessionStart": p["teal"],
64
+ "SessionEnd": p["teal"],
65
+ "PreCompact": p["peach"],
66
+ "Notification": p["lavender"],
67
+ "PermissionRequest": p["red"],
68
+ "Stop": p["dim"],
69
+ }
70
+ return mapping.get(event_type, p["overlay"])
71
+
72
+
73
+ def build_events_tab() -> Gtk.ScrolledWindow:
74
+ """Build the Events tab widget."""
75
+ global _list_box, _filter_combo, _search_entry, _auto_scroll_btn
76
+ global _scrolled_window, _stats_label
77
+
78
+ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
79
+
80
+ # --- Toolbar ---
81
+ toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
82
+ toolbar.get_style_context().add_class("log-toolbar")
83
+ toolbar.set_margin_top(8)
84
+ toolbar.set_margin_bottom(6)
85
+ toolbar.set_margin_start(10)
86
+ toolbar.set_margin_end(10)
87
+
88
+ filter_label = Gtk.Label(label="Type:")
89
+ toolbar.pack_start(filter_label, False, False, 0)
90
+
91
+ _filter_combo = Gtk.ComboBoxText()
92
+ _filter_combo.append_text("All")
93
+ for t in _KNOWN_TYPES:
94
+ _filter_combo.append_text(t)
95
+ _filter_combo.set_active(0)
96
+ _filter_combo.connect("changed", _on_filter_changed)
97
+ toolbar.pack_start(_filter_combo, False, False, 0)
98
+
99
+ session_label = Gtk.Label(label="Session:")
100
+ toolbar.pack_start(session_label, False, False, 0)
101
+
102
+ _search_entry = Gtk.SearchEntry()
103
+ _search_entry.set_placeholder_text("session ID...")
104
+ _search_entry.set_size_request(140, -1)
105
+ _search_entry.connect("search-changed", _on_filter_changed)
106
+ toolbar.pack_start(_search_entry, False, False, 0)
107
+
108
+ # Spacer
109
+ spacer = Gtk.Label(label="")
110
+ spacer.set_hexpand(True)
111
+ toolbar.pack_start(spacer, True, True, 0)
112
+
113
+ _stats_label = Gtk.Label(label="")
114
+ _stats_label.set_xalign(1.0)
115
+ _stats_label.get_style_context().add_class("log-stats")
116
+ toolbar.pack_start(_stats_label, False, False, 0)
117
+
118
+ _auto_scroll_btn = Gtk.ToggleButton(label="Auto-scroll")
119
+ _auto_scroll_btn.set_active(True)
120
+ toolbar.pack_start(_auto_scroll_btn, False, False, 0)
121
+
122
+ clear_btn = Gtk.Button(label="Clear")
123
+ clear_btn.connect("clicked", _on_clear_clicked)
124
+ toolbar.pack_start(clear_btn, False, False, 0)
125
+
126
+ outer.pack_start(toolbar, False, False, 0)
127
+ outer.pack_start(
128
+ Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0
129
+ )
130
+
131
+ # --- Scrolled list ---
132
+ _scrolled_window = Gtk.ScrolledWindow()
133
+ _scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
134
+ _scrolled_window.set_vexpand(True)
135
+
136
+ _list_box = Gtk.ListBox()
137
+ _list_box.set_selection_mode(Gtk.SelectionMode.NONE)
138
+ _scrolled_window.add(_list_box)
139
+
140
+ outer.pack_start(_scrolled_window, True, True, 0)
141
+
142
+ # Wrap in a ScrolledWindow for the tab API contract
143
+ wrapper = Gtk.ScrolledWindow()
144
+ wrapper.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER)
145
+ wrapper.add(outer)
146
+
147
+ idle_once(refresh_events)
148
+ return wrapper
149
+
150
+
151
+ def refresh_events() -> bool:
152
+ """Called by panel.py timer (2s). Reads new JSONL lines. Returns True to keep timer."""
153
+ global _file_offset, _current_day, _rows
154
+
155
+ if _list_box is None:
156
+ return True
157
+
158
+ try:
159
+ today_str = date.today().isoformat()
160
+
161
+ # Day rollover — reset offset
162
+ if _current_day != today_str:
163
+ _file_offset = 0
164
+ _current_day = today_str
165
+
166
+ today_file = EVENTS_DIR / f"{today_str}.jsonl"
167
+ if not today_file.exists():
168
+ _update_stats()
169
+ return True
170
+
171
+ new_events = []
172
+ try:
173
+ with today_file.open("rb") as f:
174
+ f.seek(_file_offset)
175
+ while True:
176
+ line = f.readline()
177
+ if not line:
178
+ break
179
+ _file_offset = f.tell()
180
+ line = line.strip()
181
+ if not line:
182
+ continue
183
+ try:
184
+ data = json.loads(line)
185
+ new_events.append(data)
186
+ except json.JSONDecodeError:
187
+ continue
188
+ except OSError:
189
+ return True
190
+
191
+ if not new_events:
192
+ return True
193
+
194
+ for event in new_events:
195
+ _rows.append(event)
196
+
197
+ # Trim to max rows
198
+ if len(_rows) > _MAX_ROWS:
199
+ excess = len(_rows) - _MAX_ROWS
200
+ _rows = _rows[excess:]
201
+ # Remove oldest rows from list_box
202
+ children = _list_box.get_children()
203
+ for i in range(min(excess, len(children))):
204
+ _list_box.remove(children[i])
205
+
206
+ # Add new rows to list_box
207
+ active_type = _current_filter_type()
208
+ session_filter = _current_session_filter()
209
+ for event in new_events:
210
+ if _event_matches(event, active_type, session_filter):
211
+ row = _build_row(event)
212
+ _list_box.add(row)
213
+
214
+ _list_box.show_all()
215
+ _update_stats()
216
+
217
+ # Auto-scroll
218
+ if _auto_scroll_btn is not None and _auto_scroll_btn.get_active():
219
+ _scroll_to_bottom()
220
+
221
+ except Exception:
222
+ pass
223
+ return True
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Row building
228
+ # ---------------------------------------------------------------------------
229
+
230
+ def _build_row(event: dict) -> Gtk.ListBoxRow:
231
+ """Create a single ListBoxRow from an event dict."""
232
+ row = Gtk.ListBoxRow()
233
+ row.set_activatable(False)
234
+
235
+ hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
236
+ hbox.set_margin_top(3)
237
+ hbox.set_margin_bottom(3)
238
+ hbox.set_margin_start(8)
239
+ hbox.set_margin_end(8)
240
+
241
+ # Time label
242
+ time_str = _parse_time(event)
243
+ time_lbl = Gtk.Label(label=time_str)
244
+ time_lbl.set_size_request(65, -1)
245
+ time_lbl.set_xalign(0.0)
246
+ time_lbl.get_style_context().add_class("log-time")
247
+ hbox.pack_start(time_lbl, False, False, 0)
248
+
249
+ # Type badge (colored via Pango markup)
250
+ event_type = event.get("hook_event_name", event.get("type", "Unknown"))
251
+ color = _type_color(event_type)
252
+ badge = Gtk.Label()
253
+ badge.set_markup(f'<span foreground="{color}" font_weight="bold">{GLib.markup_escape_text(event_type)}</span>')
254
+ badge.set_size_request(160, -1)
255
+ badge.set_xalign(0.0)
256
+ badge.set_ellipsize(Pango.EllipsizeMode.END)
257
+ hbox.pack_start(badge, False, False, 0)
258
+
259
+ # Session ID (first 8 chars)
260
+ session_id = event.get("session_id", "")
261
+ session_short = session_id[:8] if session_id else "—"
262
+ session_lbl = Gtk.Label(label=session_short)
263
+ session_lbl.set_size_request(72, -1)
264
+ session_lbl.set_xalign(0.0)
265
+ session_lbl.get_style_context().add_class("log-time")
266
+ hbox.pack_start(session_lbl, False, False, 0)
267
+
268
+ # Detail text
269
+ detail = _event_detail(event)
270
+ detail_lbl = Gtk.Label(label=detail)
271
+ detail_lbl.set_hexpand(True)
272
+ detail_lbl.set_xalign(0.0)
273
+ detail_lbl.set_ellipsize(Pango.EllipsizeMode.END)
274
+ detail_lbl.get_style_context().add_class("log-detail")
275
+ hbox.pack_start(detail_lbl, True, True, 0)
276
+
277
+ row.add(hbox)
278
+
279
+ # Tooltip: full event JSON summary
280
+ tooltip_parts = []
281
+ if session_id:
282
+ tooltip_parts.append(f"session: {session_id}")
283
+ tool = event.get("tool_name") or event.get("tool")
284
+ if tool:
285
+ tooltip_parts.append(f"tool: {tool}")
286
+ if tooltip_parts:
287
+ row.set_tooltip_text(" | ".join(tooltip_parts))
288
+
289
+ return row
290
+
291
+
292
+ def _parse_time(event: dict) -> str:
293
+ """Extract HH:MM:SS from event timestamp."""
294
+ ts = event.get("timestamp", event.get("time", ""))
295
+ if not ts:
296
+ return ""
297
+ try:
298
+ dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
299
+ return dt.astimezone().strftime("%H:%M:%S")
300
+ except (ValueError, TypeError):
301
+ return ts[:8] if len(ts) >= 8 else ts
302
+
303
+
304
+ def _event_detail(event: dict) -> str:
305
+ """Extract a human-readable detail string from an event."""
306
+ # Tool name takes priority
307
+ tool = event.get("tool_name") or event.get("tool")
308
+ if tool:
309
+ path = event.get("tool_input", {}).get("path", "") if isinstance(event.get("tool_input"), dict) else ""
310
+ if path:
311
+ home = str(Path.home())
312
+ if path.startswith(home + "/"):
313
+ path = "~/" + path[len(home) + 1:]
314
+ return f"{tool} {path}"
315
+ return tool
316
+
317
+ # Fallback: pick meaningful fields
318
+ for key in ("message", "text", "prompt", "summary", "reason", "content"):
319
+ val = event.get(key, "")
320
+ if val and isinstance(val, str):
321
+ return val[:120]
322
+
323
+ # Last resort: show non-meta keys
324
+ skip = {"timestamp", "time", "session_id", "hook_event_name", "type"}
325
+ parts = []
326
+ for k, v in event.items():
327
+ if k not in skip and not k.startswith("_"):
328
+ parts.append(f"{k}={v!r}")
329
+ if len(parts) >= 4:
330
+ break
331
+ return " ".join(parts)[:120]
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Filtering
336
+ # ---------------------------------------------------------------------------
337
+
338
+ def _current_filter_type() -> str | None:
339
+ if _filter_combo is None:
340
+ return None
341
+ active = _filter_combo.get_active_text() or "All"
342
+ return None if active == "All" else active
343
+
344
+
345
+ def _current_session_filter() -> str:
346
+ if _search_entry is None:
347
+ return ""
348
+ return (_search_entry.get_text() or "").strip().lower()
349
+
350
+
351
+ def _event_matches(event: dict, filter_type: str | None, session_filter: str) -> bool:
352
+ if filter_type is not None:
353
+ etype = event.get("hook_event_name", event.get("type", ""))
354
+ if etype != filter_type:
355
+ return False
356
+ if session_filter:
357
+ sid = (event.get("session_id", "") or "").lower()
358
+ if session_filter not in sid:
359
+ return False
360
+ return True
361
+
362
+
363
+ def _on_filter_changed(_widget):
364
+ """Rebuild visible rows when filter combo or search entry changes."""
365
+ _rebuild_list()
366
+
367
+
368
+ def _rebuild_list():
369
+ """Clear and re-populate list_box from _rows with current filters."""
370
+ if _list_box is None:
371
+ return
372
+ for child in _list_box.get_children():
373
+ _list_box.remove(child)
374
+
375
+ active_type = _current_filter_type()
376
+ session_filter = _current_session_filter()
377
+
378
+ for event in _rows:
379
+ if _event_matches(event, active_type, session_filter):
380
+ row = _build_row(event)
381
+ _list_box.add(row)
382
+
383
+ _list_box.show_all()
384
+ _update_stats()
385
+
386
+ if _auto_scroll_btn is not None and _auto_scroll_btn.get_active():
387
+ _scroll_to_bottom()
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # Actions
392
+ # ---------------------------------------------------------------------------
393
+
394
+ def _on_clear_clicked(_btn):
395
+ global _rows, _file_offset
396
+ _rows = []
397
+ _file_offset = 0 # re-read from start on next refresh (soft clear)
398
+ if _list_box is not None:
399
+ for child in _list_box.get_children():
400
+ _list_box.remove(child)
401
+ _update_stats()
402
+
403
+
404
+ def _scroll_to_bottom():
405
+ if _scrolled_window is None:
406
+ return
407
+ adj = _scrolled_window.get_vadjustment()
408
+ if adj is not None:
409
+ GLib.idle_add(_do_scroll, adj)
410
+
411
+
412
+ def _do_scroll(adj) -> bool:
413
+ adj.set_value(adj.get_upper() - adj.get_page_size())
414
+ return False
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # Stats
419
+ # ---------------------------------------------------------------------------
420
+
421
+ def _update_stats():
422
+ if _stats_label is None:
423
+ return
424
+ total = len(_rows)
425
+ now = datetime.now().strftime("%H:%M")
426
+ _stats_label.set_text(f"{total} events | {now}")
log_viewer.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Log viewer widget for Claude Code Control Panel.
2
+
3
+ Card-based log viewer with filtering and cost tracking.
4
+ Reads today's usage JSONL + provider costs + coaching log.
5
+ Auto-refreshes via external timer calling refresh_logs().
6
+ """
7
+
8
+ import json
9
+ from collections import deque
10
+ from datetime import date, datetime, timezone
11
+ from pathlib import Path
12
+
13
+ import gi
14
+ gi.require_version("Gtk", "3.0")
15
+ from gi.repository import Gtk, GLib, Pango
16
+
17
+ from utils import idle_once
18
+
19
+ USAGE_DIR = Path.home() / ".claude" / "usage"
20
+ COACHING_LOG = Path.home() / ".claude" / "hooks" / "coaching" / "coaching_log.md"
21
+ PROVIDERS_FILE = USAGE_DIR / "providers.jsonl"
22
+
23
+ _MAX_ENTRIES = 500
24
+
25
+ # Module-level widget references
26
+ _filter_combo = None
27
+ _search_entry = None
28
+ _stats_label = None
29
+ _cards_container = None
30
+ _last_entries = []
31
+ _last_fingerprint = ("", 0)
32
+
33
+
34
+ def build_logs_tab() -> Gtk.ScrolledWindow:
35
+ """Build and return the Logs tab widget."""
36
+ global _filter_combo, _search_entry, _stats_label, _cards_container
37
+
38
+ outer = Gtk.ScrolledWindow()
39
+ outer.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
40
+
41
+ main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
42
+ main_box.set_margin_top(10)
43
+ main_box.set_margin_bottom(10)
44
+ main_box.set_margin_start(10)
45
+ main_box.set_margin_end(10)
46
+
47
+ # --- Toolbar ---
48
+ toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
49
+ toolbar.get_style_context().add_class("log-toolbar")
50
+
51
+ filter_label = Gtk.Label(label="Filter:")
52
+ toolbar.pack_start(filter_label, False, False, 0)
53
+
54
+ _filter_combo = Gtk.ComboBoxText()
55
+ for label in ["Alle", "Tools", "LLM", "Errors", "Hooks"]:
56
+ _filter_combo.append_text(label)
57
+ _filter_combo.set_active(0)
58
+ _filter_combo.connect("changed", _on_filter_changed)
59
+ toolbar.pack_start(_filter_combo, False, False, 0)
60
+
61
+ _search_entry = Gtk.SearchEntry()
62
+ _search_entry.set_placeholder_text("Suche...")
63
+ _search_entry.set_size_request(160, -1)
64
+ _search_entry.connect("search-changed", _on_filter_changed)
65
+ toolbar.pack_start(_search_entry, False, False, 0)
66
+
67
+ _stats_label = Gtk.Label(label="")
68
+ _stats_label.set_xalign(1.0)
69
+ _stats_label.set_hexpand(True)
70
+ _stats_label.get_style_context().add_class("log-stats")
71
+ toolbar.pack_start(_stats_label, True, True, 0)
72
+
73
+ main_box.pack_start(toolbar, False, False, 0)
74
+ main_box.pack_start(
75
+ Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0
76
+ )
77
+
78
+ # --- Inner scroll for cards ---
79
+ inner_scroll = Gtk.ScrolledWindow()
80
+ inner_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
81
+ inner_scroll.set_vexpand(True)
82
+
83
+ _cards_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
84
+ inner_scroll.add(_cards_container)
85
+
86
+ main_box.pack_start(inner_scroll, True, True, 0)
87
+ outer.add(main_box)
88
+
89
+ idle_once(refresh_logs)
90
+ return outer
91
+
92
+
93
+ def refresh_logs() -> bool:
94
+ """Refresh log data from disk. Returns True to keep timer alive."""
95
+ global _last_fingerprint, _last_entries
96
+ try:
97
+ if _cards_container is None:
98
+ return True
99
+
100
+ entries = _read_usage_entries() + _read_coaching_entries() + _read_provider_costs_today()
101
+ entries.sort(key=lambda e: e.get("_sort_key", ""), reverse=True)
102
+ entries = entries[:_MAX_ENTRIES]
103
+
104
+ fingerprint = (entries[0].get("_sort_key", "") if entries else "", len(entries))
105
+ if fingerprint == _last_fingerprint:
106
+ _update_stats(entries)
107
+ return True
108
+
109
+ _last_entries = entries
110
+ _last_fingerprint = fingerprint
111
+ _rebuild_cards()
112
+ _update_stats(entries)
113
+ except Exception:
114
+ pass
115
+ return True
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Data reading
120
+ # ---------------------------------------------------------------------------
121
+
122
+ def _read_usage_entries() -> list:
123
+ """Read today's usage JSONL (tool calls)."""
124
+ today_file = USAGE_DIR / f"{date.today().isoformat()}.jsonl"
125
+ if not today_file.exists():
126
+ return []
127
+
128
+ entries = []
129
+ try:
130
+ with today_file.open() as f:
131
+ for line in f:
132
+ line = line.strip()
133
+ if not line:
134
+ continue
135
+ try:
136
+ data = json.loads(line)
137
+ ts = data.get("time", data.get("timestamp", ""))
138
+ data["_sort_key"] = ts
139
+ data["_type"] = "tool"
140
+ entries.append(data)
141
+ except json.JSONDecodeError:
142
+ continue
143
+ except OSError:
144
+ pass
145
+ return entries
146
+
147
+
148
+ def _read_coaching_entries() -> list:
149
+ """Read last 20 lines from coaching log as hook entries."""
150
+ if not COACHING_LOG.exists():
151
+ return []
152
+
153
+ entries = []
154
+ try:
155
+ with COACHING_LOG.open() as f:
156
+ last_lines = deque(f, maxlen=20)
157
+ for line in last_lines:
158
+ line = line.strip()
159
+ if not line or line.startswith("#"):
160
+ continue
161
+ entries.append({
162
+ "_type": "hook",
163
+ "_sort_key": "",
164
+ "text": line,
165
+ })
166
+ except OSError:
167
+ pass
168
+ return entries
169
+
170
+
171
+ def _read_provider_costs_today() -> list:
172
+ """Read today's provider entries from providers.jsonl."""
173
+ today = date.today().isoformat()
174
+ if not PROVIDERS_FILE.exists():
175
+ return []
176
+ entries = []
177
+ try:
178
+ with PROVIDERS_FILE.open() as f:
179
+ for line in f:
180
+ line = line.strip()
181
+ if not line:
182
+ continue
183
+ try:
184
+ data = json.loads(line)
185
+ ts = data.get("time", data.get("timestamp", ""))
186
+ if ts[:10] == today:
187
+ data["_sort_key"] = ts
188
+ data["_type"] = "llm"
189
+ entries.append(data)
190
+ except json.JSONDecodeError:
191
+ continue
192
+ except OSError:
193
+ pass
194
+ return entries
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Row building
199
+ # ---------------------------------------------------------------------------
200
+
201
+ def _entry_to_row(entry: dict) -> dict:
202
+ """Convert an entry dict to a row dict."""
203
+ etype = entry.get("_type", "tool")
204
+ sort_key = entry.get("_sort_key", "")
205
+
206
+ # Parse timestamp
207
+ ts_raw = entry.get("time", entry.get("timestamp", sort_key))
208
+ time_str = ""
209
+ if ts_raw:
210
+ try:
211
+ dt = datetime.fromisoformat(ts_raw.replace("Z", "+00:00"))
212
+ time_str = dt.astimezone().strftime("%H:%M:%S")
213
+ except (ValueError, TypeError):
214
+ time_str = ts_raw[:8]
215
+
216
+ if etype == "tool":
217
+ tool = entry.get("tool", "?")
218
+ file_path = entry.get("file", "")
219
+ cmd_cat = entry.get("cmd_cat", "")
220
+ detail = file_path or cmd_cat or ""
221
+ _home_prefix = str(Path.home()) + "/"
222
+ if detail.startswith(_home_prefix):
223
+ detail = "~/" + detail[len(_home_prefix):]
224
+ tooltip = f"{tool} {file_path or cmd_cat or ''}".strip()
225
+ cost_f = 0.0
226
+ cost_str = ""
227
+ if entry.get("error"):
228
+ status = "✗"
229
+ row_type = "error"
230
+ else:
231
+ status = "✓"
232
+ row_type = "tool"
233
+
234
+ elif etype == "llm":
235
+ provider = entry.get("provider", "?")
236
+ model = entry.get("model", "")
237
+ tool = f"{provider}/{model}" if model else provider
238
+ try:
239
+ cost_f = float(entry.get("cost_usd", 0.0))
240
+ except (TypeError, ValueError):
241
+ cost_f = 0.0
242
+ cost_str = f"${cost_f:.4f}" if cost_f else ""
243
+ try:
244
+ elapsed = float(entry.get("elapsed_s", 0))
245
+ detail = f"{elapsed:.1f}s" if elapsed else ""
246
+ except (TypeError, ValueError):
247
+ elapsed = 0
248
+ detail = ""
249
+ tooltip = f"{provider} {model} cost={cost_str}".strip()
250
+ status = "—"
251
+ row_type = "llm"
252
+
253
+ elif etype == "hook":
254
+ tool = "HOOK"
255
+ text = entry.get("text", "")
256
+ detail = text[:80]
257
+ tooltip = text
258
+ cost_f = 0.0
259
+ cost_str = ""
260
+ status = "—"
261
+ row_type = "hook"
262
+
263
+ else:
264
+ tool = etype
265
+ detail = ""
266
+ tooltip = ""
267
+ cost_f = 0.0
268
+ cost_str = ""
269
+ status = "—"
270
+ row_type = etype
271
+
272
+ return {
273
+ "time_str": time_str,
274
+ "tool": tool,
275
+ "detail": detail,
276
+ "cost_str": cost_str,
277
+ "status": status,
278
+ "row_type": row_type,
279
+ "tooltip": tooltip,
280
+ "cost_f": cost_f,
281
+ "sort_key": sort_key,
282
+ }
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Card building
287
+ # ---------------------------------------------------------------------------
288
+
289
+ def _build_card(row: dict) -> Gtk.Box:
290
+ """Create a single HBox card widget from a row dict."""
291
+ row_type = row["row_type"]
292
+
293
+ card = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
294
+ ctx = card.get_style_context()
295
+ ctx.add_class("log-card")
296
+ ctx.add_class(f"log-card-{row_type}")
297
+
298
+ if row["tooltip"]:
299
+ card.set_tooltip_text(row["tooltip"])
300
+
301
+ # Time label — fixed 65px
302
+ time_lbl = Gtk.Label(label=row["time_str"])
303
+ time_lbl.set_size_request(65, -1)
304
+ time_lbl.set_xalign(0.0)
305
+ time_lbl.get_style_context().add_class("log-time")
306
+ card.pack_start(time_lbl, False, False, 0)
307
+
308
+ # Tool label — fixed 120px, ellipsize
309
+ tool_lbl = Gtk.Label(label=row["tool"])
310
+ tool_lbl.set_size_request(120, -1)
311
+ tool_lbl.set_xalign(0.0)
312
+ tool_lbl.set_ellipsize(Pango.EllipsizeMode.END)
313
+ tool_lbl.get_style_context().add_class("log-tool-name")
314
+ card.pack_start(tool_lbl, False, False, 0)
315
+
316
+ # Detail label — hexpand, ellipsize
317
+ detail_lbl = Gtk.Label(label=row["detail"])
318
+ detail_lbl.set_hexpand(True)
319
+ detail_lbl.set_xalign(0.0)
320
+ detail_lbl.set_ellipsize(Pango.EllipsizeMode.END)
321
+ detail_lbl.get_style_context().add_class("log-detail")
322
+ card.pack_start(detail_lbl, True, True, 0)
323
+
324
+ # Status label — fixed 20px
325
+ status = row["status"]
326
+ status_lbl = Gtk.Label(label=status)
327
+ status_lbl.set_size_request(20, -1)
328
+ status_lbl.set_xalign(0.5)
329
+ status_ctx = status_lbl.get_style_context()
330
+ if status == "✓":
331
+ status_ctx.add_class("log-status-ok")
332
+ elif status == "✗":
333
+ status_ctx.add_class("log-status-error")
334
+ else:
335
+ status_ctx.add_class("log-status-neutral")
336
+ card.pack_start(status_lbl, False, False, 0)
337
+
338
+ # Cost label — fixed 65px, right-aligned
339
+ cost_lbl = Gtk.Label(label=row["cost_str"])
340
+ cost_lbl.set_size_request(65, -1)
341
+ cost_lbl.set_xalign(1.0)
342
+ cost_lbl.get_style_context().add_class("log-cost")
343
+ card.pack_start(cost_lbl, False, False, 0)
344
+
345
+ return card
346
+
347
+
348
+ def _rebuild_cards():
349
+ """Clear and rebuild the cards container from _last_entries."""
350
+ if _cards_container is None:
351
+ return
352
+ for child in _cards_container.get_children():
353
+ _cards_container.remove(child)
354
+
355
+ filtered = _filter_entries(_last_entries)
356
+ for entry in filtered:
357
+ row = _entry_to_row(entry)
358
+ card = _build_card(row)
359
+ _cards_container.pack_start(card, False, False, 0)
360
+ _cards_container.show_all()
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # Filtering
365
+ # ---------------------------------------------------------------------------
366
+
367
+ def _filter_entries(entries: list) -> list:
368
+ """Filter entries by combo selection and search text."""
369
+ if _filter_combo is None:
370
+ return entries
371
+
372
+ active = _filter_combo.get_active_text() or "Alle"
373
+ type_map = {
374
+ "Alle": None,
375
+ "Tools": "tool",
376
+ "LLM": "llm",
377
+ "Errors": "error",
378
+ "Hooks": "hook",
379
+ }
380
+ filter_type = type_map.get(active)
381
+
382
+ search_text = ""
383
+ if _search_entry is not None:
384
+ search_text = (_search_entry.get_text() or "").lower().strip()
385
+
386
+ result = []
387
+ for entry in entries:
388
+ etype = entry.get("_type", "tool")
389
+ # For error filtering: match on entry error field
390
+ if filter_type == "error":
391
+ if not entry.get("error"):
392
+ continue
393
+ elif filter_type is not None:
394
+ if etype != filter_type:
395
+ continue
396
+
397
+ if search_text:
398
+ tool_val = (entry.get("tool", "") or entry.get("provider", "") or etype or "").lower()
399
+ detail_val = (
400
+ entry.get("file", "") or
401
+ entry.get("cmd_cat", "") or
402
+ entry.get("text", "") or
403
+ entry.get("model", "") or
404
+ ""
405
+ ).lower()
406
+ if search_text not in tool_val and search_text not in detail_val:
407
+ continue
408
+
409
+ result.append(entry)
410
+ return result
411
+
412
+
413
+ def _on_filter_changed(_widget):
414
+ """Rebuild cards when combo or search entry changes."""
415
+ _rebuild_cards()
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # Stats
420
+ # ---------------------------------------------------------------------------
421
+
422
+ def _update_stats(entries: list):
423
+ if _stats_label is None:
424
+ return
425
+ tool_count = sum(1 for e in entries if e.get("_type") == "tool")
426
+ error_count = sum(1 for e in entries if e.get("error"))
427
+ total_cost = sum(e.get("cost_usd", 0.0) for e in entries if e.get("_type") == "llm")
428
+ now = datetime.now().strftime("%H:%M")
429
+ parts = [f"{tool_count} calls"]
430
+ if error_count:
431
+ parts.append(f"{error_count} errors")
432
+ if total_cost:
433
+ parts.append(f"${total_cost:.3f}")
434
+ parts.append(now)
435
+ _stats_label.set_text(" | ".join(parts))
monitor.py ADDED
@@ -0,0 +1,802 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Monitor data layer for Claude Code Control Panel.
2
+
3
+ Reads usage data, active sessions, missed skills, and recent sessions.
4
+ Gracefully falls back when data sources are unavailable.
5
+
6
+ Performance: TTL cache (30s) prevents redundant disk I/O.
7
+ Single combined scan for active + recent sessions.
8
+ Session previews cached permanently (content never changes).
9
+ Sidecar socket queries refresh asynchronously to avoid blocking GTK.
10
+ """
11
+
12
+ import importlib.util
13
+ import json
14
+ import logging
15
+ import threading
16
+ import time
17
+ from collections import Counter
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ log = logging.getLogger("claude_panel.monitor")
22
+
23
+
24
+ def _load_usage_reader():
25
+ """Load usage_reader from MyAIGame if available, without sys.path manipulation."""
26
+ module_path = Path.home() / "Projekte" / "MyAIGame" / "tui" / "data" / "usage_reader.py"
27
+ if not module_path.is_file():
28
+ return None
29
+ spec = importlib.util.spec_from_file_location("usage_reader", module_path)
30
+ if spec is None or spec.loader is None:
31
+ return None
32
+ mod = importlib.util.module_from_spec(spec)
33
+ spec.loader.exec_module(mod)
34
+ return mod
35
+
36
+
37
+ def _load_skill_tracker():
38
+ """Load skill_tracker from MyAIGame if available, without sys.path manipulation."""
39
+ module_path = Path.home() / "Projekte" / "MyAIGame" / "tui" / "data" / "skill_tracker.py"
40
+ if not module_path.is_file():
41
+ return None
42
+ spec = importlib.util.spec_from_file_location("skill_tracker", module_path)
43
+ if spec is None or spec.loader is None:
44
+ return None
45
+ mod = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(mod)
47
+ return mod
48
+
49
+
50
+ # Try loading TUI data modules via explicit importlib (avoids sys.path DLL-hijacking)
51
+ _usage_reader_mod = None
52
+ try:
53
+ _usage_reader_mod = _load_usage_reader()
54
+ _HAS_USAGE_READER = _usage_reader_mod is not None
55
+ except Exception:
56
+ log.warning("usage_reader not available"); _HAS_USAGE_READER = False
57
+
58
+ _skill_tracker_mod = None
59
+ try:
60
+ _skill_tracker_mod = _load_skill_tracker()
61
+ _HAS_SKILL_TRACKER = _skill_tracker_mod is not None
62
+ except Exception:
63
+ log.warning("skill_tracker not available"); _HAS_SKILL_TRACKER = False
64
+
65
+
66
+ PROJECTS_DIR = Path.home() / ".claude" / "projects"
67
+ USAGE_DIR = Path.home() / ".claude" / "usage"
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # TTL Cache — 30s for data that changes, permanent for session previews
71
+ # ---------------------------------------------------------------------------
72
+ _CACHE_TTL_SECONDS = 30
73
+ _cache = {} # key -> value
74
+ _cache_ts = {} # key -> timestamp
75
+ _preview_cache = {} # path_str -> preview text (permanent, content never changes)
76
+ _sidecar_lock = threading.Lock()
77
+ _sidecar_refresh_in_flight = False
78
+ _jsonl_scan_lock = threading.Lock()
79
+ _jsonl_scan_in_flight = False
80
+
81
+
82
+ def _cache_get(key):
83
+ """Return cached value if still within TTL, else None."""
84
+ if key in _cache and (time.time() - _cache_ts.get(key, 0)) < _CACHE_TTL_SECONDS:
85
+ return _cache[key]
86
+ return None
87
+
88
+
89
+ def _cache_set(key, value, ttl=None):
90
+ """Store value in TTL cache. Optional custom ttl in seconds."""
91
+ _cache[key] = value
92
+ if ttl is not None:
93
+ _cache_ts[key] = time.time() - (_CACHE_TTL_SECONDS - ttl)
94
+ else:
95
+ _cache_ts[key] = time.time()
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Anthropic token pricing (per 1M tokens) — Claude 4.x / 4.5 generation
100
+ # model_id -> (input_$/1M, cache_write_$/1M, cache_read_$/1M, output_$/1M)
101
+ # Source: https://docs.anthropic.com/en/docs/about-claude/models (March 2026)
102
+ # ---------------------------------------------------------------------------
103
+ ANTHROPIC_PRICING = {
104
+ "claude-opus-4-6": (5.00, 6.25, 0.50, 25.0),
105
+ "claude-sonnet-4-6": (3.00, 3.75, 0.30, 15.0),
106
+ "claude-haiku-4-5-20251001": (1.00, 1.25, 0.10, 5.0),
107
+ }
108
+ DEFAULT_PRICING = (5.00, 6.25, 0.50, 25.0) # Opus as conservative fallback
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Combined JSONL scanner — single pass for tokens + skills (cached 30s)
113
+ # ---------------------------------------------------------------------------
114
+ def _default_jsonl_scan() -> dict:
115
+ """Return empty result structure for JSONL scan."""
116
+ return {"tokens_by_date": {}, "models_by_date": {}, "skills_by_date": {}}
117
+
118
+
119
+ def _do_jsonl_scan(days: int, cache_key: str) -> None:
120
+ """Heavy JSONL scan — runs in background thread, stores result in cache."""
121
+ global _jsonl_scan_in_flight
122
+ try:
123
+ result = _do_jsonl_scan_inner(days)
124
+ _cache_set(cache_key, result)
125
+ except Exception:
126
+ log.exception("background JSONL scan failed")
127
+ finally:
128
+ with _jsonl_scan_lock:
129
+ _jsonl_scan_in_flight = False
130
+
131
+
132
+ def _scan_session_jsonls(days: int = 1) -> dict:
133
+ """Non-blocking JSONL scan. Returns cached/stale data immediately,
134
+ kicks off background thread on cache miss (like get_sidecar_status).
135
+ """
136
+ global _jsonl_scan_in_flight
137
+
138
+ cache_key = f"jsonl_scan_{days}"
139
+ cached = _cache_get(cache_key)
140
+ if cached is not None:
141
+ return cached
142
+
143
+ stale = _cache.get(cache_key, _default_jsonl_scan())
144
+
145
+ with _jsonl_scan_lock:
146
+ if not _jsonl_scan_in_flight:
147
+ _jsonl_scan_in_flight = True
148
+ threading.Thread(
149
+ target=_do_jsonl_scan,
150
+ args=(days, cache_key),
151
+ daemon=True,
152
+ name="claude-panel-jsonl-scan",
153
+ ).start()
154
+
155
+ return stale
156
+
157
+
158
+ def _do_jsonl_scan_inner(days: int = 1) -> dict:
159
+ """Single-pass scan of session JSONLs for tokens AND skills.
160
+
161
+ Returns: {
162
+ "tokens_by_date": {"2026-03-03": {"input": N, "cache_create": N, "cache_read": N, "output": N, "cost": F}},
163
+ "skills_by_date": {"2026-03-03": {"research": 3, "chef": 5}},
164
+ }
165
+ """
166
+ from collections import defaultdict
167
+
168
+ now = time.time()
169
+ cutoff_ts = now - (days * 86400)
170
+
171
+ tokens_by_date = defaultdict(lambda: {"input": 0, "cache_create": 0, "cache_read": 0, "output": 0, "cost": 0.0})
172
+ # Per-model breakdown: date -> model_tier -> {input, cache_create, cache_read, output, cost}
173
+ tokens_by_date_model = defaultdict(lambda: defaultdict(
174
+ lambda: {"input": 0, "cache_create": 0, "cache_read": 0, "output": 0, "cost": 0.0}
175
+ ))
176
+ skills_by_date = defaultdict(lambda: defaultdict(int))
177
+ sessions_by_date = defaultdict(set)
178
+
179
+ if not PROJECTS_DIR.exists():
180
+ return _default_jsonl_scan()
181
+
182
+ # Collect last usage per requestId to avoid streaming duplication.
183
+ # Streaming responses emit multiple usage objects per request — only
184
+ # the final one contains the cumulative (correct) token counts.
185
+ # Key: requestId -> (date_str, model_id, usage_dict, file_path)
186
+ request_usage = {}
187
+
188
+ for project_dir in PROJECTS_DIR.iterdir():
189
+ if not project_dir.is_dir():
190
+ continue
191
+ for f in project_dir.glob("**/*.jsonl"):
192
+ try:
193
+ mtime = f.stat().st_mtime
194
+ except (OSError, PermissionError):
195
+ continue
196
+ if mtime < cutoff_ts:
197
+ continue
198
+
199
+ try:
200
+ with f.open() as fh:
201
+ for line in fh:
202
+ has_usage = '"usage"' in line
203
+ has_skill = '"name":"Skill"' in line or '"name": "Skill"' in line
204
+ if not has_usage and not has_skill:
205
+ continue
206
+ try:
207
+ entry = json.loads(line)
208
+ except json.JSONDecodeError:
209
+ continue
210
+
211
+ # Extract date from timestamp
212
+ ts = entry.get("timestamp", "")
213
+ if not ts:
214
+ continue
215
+ date_str = ts[:10] # "2026-03-03T..." -> "2026-03-03"
216
+
217
+ msg = entry.get("message", {})
218
+
219
+ # --- Token usage (dedup by requestId) ---
220
+ if has_usage:
221
+ usage = msg.get("usage")
222
+ rid = entry.get("requestId", "")
223
+ if usage and rid:
224
+ model_id = msg.get("model", "")
225
+ # Always overwrite — last entry per requestId wins
226
+ request_usage[rid] = (date_str, model_id, usage, str(f))
227
+
228
+ # --- Skill usage ---
229
+ if has_skill:
230
+ content = msg.get("content", [])
231
+ if isinstance(content, list):
232
+ for block in content:
233
+ if isinstance(block, dict) and block.get("name") == "Skill":
234
+ skill_input = block.get("input", {})
235
+ skill_name = skill_input.get("skill", "unknown")
236
+ skills_by_date[date_str][skill_name] += 1
237
+ except (OSError, PermissionError):
238
+ continue
239
+
240
+ # Aggregate deduplicated usage into date buckets (total + per-model)
241
+ for date_str, model_id, usage, file_path in request_usage.values():
242
+ rate_in, rate_write, rate_read, rate_out = ANTHROPIC_PRICING.get(model_id, DEFAULT_PRICING)
243
+
244
+ inp = usage.get("input_tokens", 0)
245
+ cache_create = usage.get("cache_creation_input_tokens", 0)
246
+ cache_read = usage.get("cache_read_input_tokens", 0)
247
+ out = usage.get("output_tokens", 0)
248
+
249
+ cost = (inp * rate_in
250
+ + cache_create * rate_write
251
+ + cache_read * rate_read
252
+ + out * rate_out) / 1_000_000
253
+
254
+ # Total bucket
255
+ bucket = tokens_by_date[date_str]
256
+ bucket["input"] += inp
257
+ bucket["cache_create"] += cache_create
258
+ bucket["cache_read"] += cache_read
259
+ bucket["output"] += out
260
+ bucket["cost"] += cost
261
+
262
+ # Per-model bucket (derive tier from model_id)
263
+ if "opus" in model_id:
264
+ tier = "opus"
265
+ elif "sonnet" in model_id:
266
+ tier = "sonnet"
267
+ elif "haiku" in model_id:
268
+ tier = "haiku"
269
+ else:
270
+ tier = "opus" # conservative fallback
271
+ mb = tokens_by_date_model[date_str][tier]
272
+ mb["input"] += inp
273
+ mb["cache_create"] += cache_create
274
+ mb["cache_read"] += cache_read
275
+ mb["output"] += out
276
+ mb["cost"] += cost
277
+
278
+ sessions_by_date[date_str].add(file_path)
279
+
280
+ # Convert defaultdicts to regular dicts and add session counts
281
+ tokens_dict = {}
282
+ for d, bucket in tokens_by_date.items():
283
+ tokens_dict[d] = {
284
+ "input": bucket["input"],
285
+ "cache_create": bucket["cache_create"],
286
+ "cache_read": bucket["cache_read"],
287
+ "output": bucket["output"],
288
+ "cost": round(bucket["cost"], 6),
289
+ "sessions": len(sessions_by_date.get(d, set())),
290
+ }
291
+
292
+ # Convert per-model defaultdicts
293
+ models_dict = {}
294
+ for d, model_map in tokens_by_date_model.items():
295
+ models_dict[d] = {tier: dict(b) for tier, b in model_map.items()}
296
+
297
+ return {
298
+ "tokens_by_date": tokens_dict,
299
+ "models_by_date": models_dict,
300
+ "skills_by_date": {k: dict(v) for k, v in skills_by_date.items()},
301
+ }
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Cost & Tools
306
+ # ---------------------------------------------------------------------------
307
+ def get_anthropic_session_cost() -> dict:
308
+ """Get real API token costs from session JSONL files for today (cached 30s).
309
+
310
+ Parses ~/.claude/projects/*/*.jsonl for actual Anthropic API usage data,
311
+ with model-specific pricing and cache token breakdown.
312
+ """
313
+ cached = _cache_get("anthropic_session_cost")
314
+ if cached is not None:
315
+ return cached
316
+
317
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
318
+ scan = _scan_session_jsonls(days=1)
319
+ bucket = scan["tokens_by_date"].get(today, {})
320
+
321
+ # Per-model breakdown for today
322
+ models_today = scan["models_by_date"].get(today, {})
323
+ models = {}
324
+ for tier, mb in models_today.items():
325
+ models[tier] = {
326
+ "cost_usd": round(mb.get("cost", 0.0), 4),
327
+ "input_tokens": mb.get("input", 0) + mb.get("cache_create", 0),
328
+ "cache_read_tokens": mb.get("cache_read", 0),
329
+ "output_tokens": mb.get("output", 0),
330
+ }
331
+
332
+ result = {
333
+ "cost_usd": round(bucket.get("cost", 0.0), 4),
334
+ "input_tokens": bucket.get("input", 0) + bucket.get("cache_create", 0),
335
+ "cache_read_tokens": bucket.get("cache_read", 0),
336
+ "output_tokens": bucket.get("output", 0),
337
+ "sessions": bucket.get("sessions", 0),
338
+ "models": models,
339
+ }
340
+ _cache_set("anthropic_session_cost", result)
341
+ return result
342
+
343
+
344
+ def _get_tool_call_stats() -> dict:
345
+ """Get tool call count and unique tool count from usage-tracker data."""
346
+ if not _HAS_USAGE_READER:
347
+ return {"total_calls": 0, "unique_tools": 0}
348
+ try:
349
+ summary = _usage_reader_mod.get_daily_summary()
350
+ return {
351
+ "total_calls": summary.get("total_calls", 0),
352
+ "unique_tools": summary.get("unique_tools", 0),
353
+ }
354
+ except Exception:
355
+ log.exception("get_skill_usage")
356
+ return {"total_calls": 0, "unique_tools": 0}
357
+
358
+
359
+ def get_daily_cost() -> dict:
360
+ """Get today's cost summary based on real token data (cached 30s)."""
361
+ cached = _cache_get("daily_cost")
362
+ if cached is not None:
363
+ return cached
364
+
365
+ provider_costs = get_provider_costs()
366
+ total = sum(provider_costs.values())
367
+
368
+ # Tool-call count for statistics (not for cost calculation)
369
+ tool_stats = _get_tool_call_stats()
370
+
371
+ result = {
372
+ "cost_estimate_usd": round(total, 4),
373
+ "total_calls": tool_stats["total_calls"],
374
+ "unique_tools": tool_stats["unique_tools"],
375
+ "source": "token-based",
376
+ }
377
+ _cache_set("daily_cost", result)
378
+ return result
379
+
380
+
381
+ def get_top_tools(n: int = 5) -> list[tuple[str, int]]:
382
+ """Get top N tools by usage count today (cached 30s)."""
383
+ cached = _cache_get(f"top_tools_{n}")
384
+ if cached is not None:
385
+ return cached
386
+ if not _HAS_USAGE_READER:
387
+ return []
388
+ try:
389
+ result = _usage_reader_mod.get_top_n(n)
390
+ except Exception:
391
+ log.exception("get_top_tools")
392
+ result = []
393
+ _cache_set(f"top_tools_{n}", result)
394
+ return result
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # Sessions — single combined scan, shared cache
399
+ # ---------------------------------------------------------------------------
400
+ def _scan_sessions():
401
+ """Single scan of all session files. Returns (active_list, all_files_sorted).
402
+
403
+ active_list: sessions modified in last hour, sorted by age.
404
+ all_files_sorted: (Path, os.stat_result, Path) tuples sorted by mtime desc.
405
+ """
406
+ cached = _cache_get("session_scan")
407
+ if cached is not None:
408
+ return cached
409
+
410
+ active = []
411
+ all_files = []
412
+ if not PROJECTS_DIR.exists():
413
+ result = (active, all_files)
414
+ _cache_set("session_scan", result)
415
+ return result
416
+
417
+ now = time.time()
418
+ for project_dir in PROJECTS_DIR.iterdir():
419
+ if not project_dir.is_dir():
420
+ continue
421
+ for f in project_dir.glob("**/*.jsonl"):
422
+ try:
423
+ stat = f.stat()
424
+ except (OSError, PermissionError):
425
+ continue
426
+
427
+ age_hours = (now - stat.st_mtime) / 3600
428
+ if age_hours < 1:
429
+ active.append({
430
+ "project": project_dir.name[:40],
431
+ "file": f.name,
432
+ "session_id": f.stem,
433
+ "age_min": int(age_hours * 60),
434
+ })
435
+
436
+ all_files.append((f, stat, project_dir))
437
+
438
+ active.sort(key=lambda s: s["age_min"])
439
+ all_files.sort(key=lambda x: x[1].st_mtime, reverse=True)
440
+
441
+ result = (active, all_files)
442
+ _cache_set("session_scan", result)
443
+ return result
444
+
445
+
446
+ def get_active_sessions() -> list[dict]:
447
+ """Scan ~/.claude/projects/ for recently active sessions (last hour)."""
448
+ active, _ = _scan_sessions()
449
+ return active
450
+
451
+
452
+ def get_recent_sessions(n: int = 5) -> list[dict]:
453
+ """Get n most recent session files across all projects for resume dropdown."""
454
+ _, all_files = _scan_sessions()
455
+ sessions = []
456
+ for f, stat, project_dir in all_files[:n]:
457
+ preview = _get_session_preview(f)
458
+ project_name = project_dir.name.replace("-home-smlflg-", "").replace("-home-smlflg", "~")
459
+ if not project_name or project_name == "~":
460
+ project_name = "Home"
461
+
462
+ sessions.append({
463
+ "path": str(f),
464
+ "project": project_name,
465
+ "session_id": f.stem,
466
+ "mtime": stat.st_mtime,
467
+ "time_str": datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m %H:%M"),
468
+ "preview": preview,
469
+ })
470
+ return sessions
471
+
472
+
473
+ def _get_session_preview(path: Path, max_len: int = 60) -> str:
474
+ """Extract first user message from session JSONL as preview text.
475
+
476
+ Cached permanently — session content doesn't change after creation.
477
+ """
478
+ key = str(path)
479
+ if key in _preview_cache:
480
+ return _preview_cache[key]
481
+
482
+ result = "(keine Vorschau)"
483
+ try:
484
+ with path.open() as f:
485
+ for line in f:
486
+ line = line.strip()
487
+ if not line:
488
+ continue
489
+ try:
490
+ entry = json.loads(line)
491
+ if entry.get("type") == "user":
492
+ msg = entry.get("message", {})
493
+ content = msg.get("content", "")
494
+ if isinstance(content, list):
495
+ for block in content:
496
+ if isinstance(block, dict) and block.get("type") == "text":
497
+ content = block.get("text", "")
498
+ break
499
+ else:
500
+ content = str(content)
501
+ if isinstance(content, str):
502
+ content = content.strip().split("\n")[0]
503
+ if len(content) > max_len:
504
+ content = content[:max_len] + "..."
505
+ result = content
506
+ break
507
+ except json.JSONDecodeError:
508
+ continue
509
+ except (OSError, PermissionError):
510
+ pass
511
+
512
+ _preview_cache[key] = result
513
+ return result
514
+
515
+
516
+ # ---------------------------------------------------------------------------
517
+ # Skill Usage (from session JSONLs)
518
+ # ---------------------------------------------------------------------------
519
+ def get_skill_usage(days: int = 1) -> dict[str, dict[str, int]]:
520
+ """Get skill usage counts grouped by date.
521
+
522
+ Returns: {"2026-03-03": {"research": 3, "chef": 5, ...}, ...}
523
+ """
524
+ cached = _cache_get(f"skill_usage_{days}")
525
+ if cached is not None:
526
+ return cached
527
+
528
+ scan = _scan_session_jsonls(days=days)
529
+ result = scan["skills_by_date"]
530
+ _cache_set(f"skill_usage_{days}", result)
531
+ return result
532
+
533
+
534
+ # ---------------------------------------------------------------------------
535
+ # Skills (missed skills from MyAIGame tracker)
536
+ # ---------------------------------------------------------------------------
537
+ def get_missed_skills() -> list[dict]:
538
+ """Get today's missed skill suggestions."""
539
+ if not _HAS_SKILL_TRACKER:
540
+ return []
541
+ try:
542
+ return _skill_tracker_mod.get_missed_today()
543
+ except Exception:
544
+ log.exception("get_missed_skills_summary")
545
+ return []
546
+
547
+
548
+ def get_missed_skills_summary() -> list[tuple[str, int]]:
549
+ """Get missed skills grouped by skill name with counts (cached 30s)."""
550
+ cached = _cache_get("missed_skills")
551
+ if cached is not None:
552
+ return cached
553
+ missed = get_missed_skills()
554
+ if not missed:
555
+ result = []
556
+ else:
557
+ counts = Counter(m.get("suggested_skill", "?") for m in missed)
558
+ result = counts.most_common(10)
559
+ _cache_set("missed_skills", result)
560
+ return result
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # Timeline
565
+ # ---------------------------------------------------------------------------
566
+ def get_usage_timeline() -> list[dict]:
567
+ """Get usage data by date (last 7 days, cached 30s).
568
+
569
+ Combines tool-call counts from usage-tracker JSONL files with
570
+ real token costs from session JSONL files (model-specific pricing).
571
+ """
572
+ cached = _cache_get("usage_timeline")
573
+ if cached is not None:
574
+ return cached
575
+
576
+ # 1. Collect call counts per day from usage-tracker JSONL
577
+ calls_by_date: dict[str, int] = {}
578
+ if USAGE_DIR.exists():
579
+ for f in sorted(USAGE_DIR.glob("????-??-??.jsonl"), reverse=True)[:7]:
580
+ count = 0
581
+ try:
582
+ with f.open() as fh:
583
+ for line in fh:
584
+ if line.strip():
585
+ count += 1
586
+ except OSError:
587
+ pass
588
+ calls_by_date[f.stem] = count
589
+
590
+ # 2. Real token costs from session JSONLs (7-day scan)
591
+ scan = _scan_session_jsonls(days=7)
592
+ tokens_by_date = scan["tokens_by_date"]
593
+
594
+ # 3. Merge: all dates from both sources, sorted desc, limit 7
595
+ all_dates = sorted(set(calls_by_date) | set(tokens_by_date), reverse=True)[:7]
596
+ timeline = []
597
+ for date_str in all_dates:
598
+ calls = calls_by_date.get(date_str, 0)
599
+ bucket = tokens_by_date.get(date_str, {})
600
+ cost = bucket.get("cost", 0.0)
601
+ inp = bucket.get("input", 0) + bucket.get("cache_create", 0)
602
+ cache_read = bucket.get("cache_read", 0)
603
+ out = bucket.get("output", 0)
604
+ timeline.append({
605
+ "date": date_str,
606
+ "calls": calls,
607
+ "cost_est": round(cost, 4),
608
+ "input_tokens": inp,
609
+ "cache_read_tokens": cache_read,
610
+ "output_tokens": out,
611
+ })
612
+
613
+ _cache_set("usage_timeline", timeline)
614
+ return timeline
615
+
616
+
617
+ def get_provider_costs() -> dict[str, float]:
618
+ """Get today's cost breakdown by provider (cached 30s).
619
+
620
+ Anthropic: real API tokens from session JSONLs (model-specific pricing).
621
+ Others: from ~/.claude/usage/providers.jsonl (minimax, codex, gemini).
622
+ """
623
+ cached = _cache_get("provider_costs")
624
+ if cached is not None:
625
+ return cached
626
+
627
+ # 1. Anthropic: per-model costs from session JSONLs
628
+ anthropic = get_anthropic_session_cost()
629
+ costs: dict[str, float] = {}
630
+ for tier in ("opus", "sonnet", "haiku"):
631
+ tier_cost = anthropic.get("models", {}).get(tier, {}).get("cost_usd", 0.0)
632
+ if tier_cost > 0:
633
+ costs[tier] = tier_cost
634
+
635
+ # 2. Other providers: from providers.jsonl
636
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
637
+ providers_file = USAGE_DIR / "providers.jsonl"
638
+ if providers_file.exists():
639
+ try:
640
+ with providers_file.open() as f:
641
+ for line in f:
642
+ line = line.strip()
643
+ if not line:
644
+ continue
645
+ try:
646
+ entry = json.loads(line)
647
+ entry_date = entry.get("time", "")[:10]
648
+ if entry_date == today:
649
+ provider = entry.get("provider", "unknown")
650
+ cost = entry.get("cost_usd", 0.0)
651
+ costs[provider] = costs.get(provider, 0.0) + cost
652
+ except (json.JSONDecodeError, KeyError):
653
+ continue
654
+ except OSError:
655
+ pass
656
+
657
+ # Round all values
658
+ costs = {k: round(v, 6) for k, v in costs.items()}
659
+
660
+ _cache_set("provider_costs", costs)
661
+ return costs
662
+
663
+
664
+ def format_cost(usd: float) -> str:
665
+ """Format USD cost for display."""
666
+ if usd < 0.01:
667
+ return f"${usd:.4f}"
668
+ return f"${usd:.2f}"
669
+
670
+
671
+ # ---------------------------------------------------------------------------
672
+ # Sidecar Watcher — queries Sidecar V6 daemon via /tmp/sidecar-v6.sock
673
+ # ---------------------------------------------------------------------------
674
+
675
+
676
+ def _category_to_severity(category: str) -> str:
677
+ """Map V6 mechanism category to UI severity level."""
678
+ if category == "safety":
679
+ return "critical"
680
+ if category in ("adhs", "context"):
681
+ return "warning"
682
+ return "info"
683
+
684
+
685
+ def _default_sidecar_status() -> dict:
686
+ return {
687
+ "running": False,
688
+ "sessions": 0,
689
+ "active_findings": [],
690
+ "detectors": {},
691
+ "mechanisms_list": [],
692
+ "mechanisms_total": 0,
693
+ "mechanisms_active": 0,
694
+ "phase": "",
695
+ }
696
+
697
+
698
+ def _query_sidecar_status() -> dict:
699
+ """Query Sidecar V6 daemon via Unix socket (blocking worker helper)."""
700
+ result = _default_sidecar_status()
701
+ sock_path = Path("/tmp/sidecar-v6.sock")
702
+ if not sock_path.exists():
703
+ return result
704
+
705
+ try:
706
+ import socket as sock_mod
707
+
708
+ def _query(request: dict) -> dict:
709
+ s = sock_mod.socket(sock_mod.AF_UNIX, sock_mod.SOCK_STREAM)
710
+ # Keep timeouts tight so a wedged sidecar cannot pile up worker
711
+ # threads even though we no longer block GTK.
712
+ s.settimeout(0.75)
713
+ try:
714
+ s.connect(str(sock_path))
715
+ s.sendall(json.dumps(request).encode())
716
+ s.shutdown(sock_mod.SHUT_WR) # signal EOF so daemon responds
717
+ data = b""
718
+ while True:
719
+ chunk = s.recv(65536)
720
+ if not chunk:
721
+ break
722
+ data += chunk
723
+ return json.loads(data.decode().strip())
724
+ finally:
725
+ s.close()
726
+
727
+ # Query status
728
+ status_resp = _query({"type": "ctl", "cmd": "status"})
729
+ result["running"] = True
730
+
731
+ session_data = status_resp.get("session", {})
732
+ result["phase"] = session_data.get("phase", "")
733
+
734
+ # Count V6 session files
735
+ v6_sessions = list(Path("/tmp").glob("sidecar-v6-ses-*.json"))
736
+ result["sessions"] = len(v6_sessions)
737
+
738
+ # Query ranking for mechanism info
739
+ try:
740
+ ranking_resp = _query({"type": "ctl", "cmd": "ranking"})
741
+ result["mechanisms_total"] = ranking_resp.get("total", 0)
742
+ result["mechanisms_active"] = ranking_resp.get("active", 0)
743
+
744
+ # Build detectors dict for UI (panel.py, swarm_tab.py)
745
+ mechanisms = ranking_resp.get("mechanisms", [])
746
+ detectors = {}
747
+ for m in mechanisms:
748
+ name = m.get("name", "")
749
+ if not name:
750
+ continue
751
+ detectors[name] = {
752
+ "active": m.get("active", False),
753
+ "severity": _category_to_severity(m.get("category", "")),
754
+ "count": m.get("fire_count", 0),
755
+ "last_seen": m.get("last_seen", ""),
756
+ }
757
+ result["detectors"] = detectors
758
+ result["mechanisms_list"] = mechanisms # full data for Monitor tab
759
+ result["active_findings"] = [
760
+ n for n, d in detectors.items() if d["active"]
761
+ ][:10]
762
+ except Exception:
763
+ pass # ranking query is optional
764
+
765
+ except Exception:
766
+ log.exception("sidecar v6 query")
767
+
768
+ return result
769
+
770
+
771
+ def _refresh_sidecar_status_async(cache_key: str) -> None:
772
+ """Refresh sidecar status in a background thread."""
773
+ global _sidecar_refresh_in_flight
774
+ try:
775
+ _cache_set(cache_key, _query_sidecar_status(), ttl=10)
776
+ finally:
777
+ with _sidecar_lock:
778
+ _sidecar_refresh_in_flight = False
779
+
780
+
781
+ def get_sidecar_status() -> dict:
782
+ """Return the latest known Sidecar V6 status without blocking GTK."""
783
+ global _sidecar_refresh_in_flight
784
+
785
+ cache_key = "sidecar_status"
786
+ cached = _cache_get(cache_key)
787
+ if cached is not None:
788
+ return cached
789
+
790
+ stale = _cache.get(cache_key, _default_sidecar_status())
791
+
792
+ with _sidecar_lock:
793
+ if not _sidecar_refresh_in_flight:
794
+ _sidecar_refresh_in_flight = True
795
+ threading.Thread(
796
+ target=_refresh_sidecar_status_async,
797
+ args=(cache_key,),
798
+ daemon=True,
799
+ name="claude-panel-sidecar",
800
+ ).start()
801
+
802
+ return stale
panel.py ADDED
The diff for this file is too large to render. See raw diff
 
process_manager.py ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Process Manager — GTK3 widget module for Claude Code Control Panel.
3
+
4
+ Provides build_processes_tab() returning a Gtk.ScrolledWindow with:
5
+ - List of all Claude-relevant processes (opencode-mcp, gemini-mcp, claude CLI, etc.)
6
+ - Per-process: PID, name, RAM (MB), CPU%, uptime, start date
7
+ - Kill button per process row with confirmation dialog
8
+ - "Kill All Ghosts" button (kills processes older than 24h)
9
+ - Summary stats at top: total processes, total RAM, ghost count
10
+ - Auto-refresh every 30s via GLib timer
11
+
12
+ Theme: Catppuccin Mocha (dark) / Latte (light) via theme.py
13
+ """
14
+
15
+ import logging
16
+ import os
17
+ import signal
18
+ import subprocess
19
+ import threading
20
+ import time
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+
24
+ log = logging.getLogger("claude_panel.process_manager")
25
+
26
+ import gi
27
+
28
+ gi.require_version("Gtk", "3.0")
29
+ from gi.repository import GLib, Gtk, Pango
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Constants
33
+ # ---------------------------------------------------------------------------
34
+
35
+ from theme import get_palette, hex_to_pango_rgb
36
+ from utils import idle_once, short_name_from_path, HOME, CLK_TCK
37
+
38
+ GHOST_HOURS = 24 # processes older than this are "ghosts"
39
+
40
+ # Process name keywords to detect Claude-relevant processes
41
+ PROCESS_KEYWORDS = [
42
+ "opencode-mcp",
43
+ "opencode",
44
+ "gemini-mcp",
45
+ "server-filesystem",
46
+ "server-memory",
47
+ "server-github",
48
+ "server-postgres",
49
+ "claude",
50
+ "codex",
51
+ "mcp-server",
52
+ ]
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Module-level state (kept between refreshes)
56
+ # ---------------------------------------------------------------------------
57
+ _list_box: Gtk.ListBox | None = None
58
+ _stats_label: Gtk.Label | None = None
59
+ _all_processes: list[dict] = []
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Process scanning
64
+ # ---------------------------------------------------------------------------
65
+
66
+ def _scan_processes() -> list[dict]:
67
+ """Run ps aux and filter for Claude-relevant processes.
68
+
69
+ Returns list of dicts with: pid, name, cpu, ram_mb, start_str, uptime_h, is_ghost
70
+ """
71
+ processes = []
72
+ try:
73
+ result = subprocess.run(
74
+ ["ps", "aux", "--no-headers"],
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=10,
78
+ )
79
+ if result.returncode != 0:
80
+ return processes
81
+
82
+ now = time.time()
83
+
84
+ for line in result.stdout.splitlines():
85
+ parts = line.split(None, 10)
86
+ if len(parts) < 11:
87
+ continue
88
+
89
+ # ps aux columns: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
90
+ try:
91
+ pid = int(parts[1])
92
+ cpu = float(parts[2])
93
+ rss_kb = int(parts[5])
94
+ tty = parts[6] # e.g. "pts/33" or "?"
95
+ start_str = parts[8] # e.g. "10:30" or "Feb12"
96
+ time_str = parts[9] # cumulative CPU time e.g. "0:00"
97
+ command = parts[10]
98
+ except (ValueError, IndexError):
99
+ continue
100
+
101
+ # Check if this process matches any keyword
102
+ cmd_lower = command.lower()
103
+ matched_keyword = None
104
+ for kw in PROCESS_KEYWORDS:
105
+ if kw in cmd_lower:
106
+ matched_keyword = kw
107
+ break
108
+ if matched_keyword is None:
109
+ continue
110
+
111
+ # Skip self (the ps command or this Python process)
112
+ if pid == os.getpid():
113
+ continue
114
+
115
+ # Derive display name from command
116
+ name = _derive_name(command, matched_keyword, pid)
117
+
118
+ # RAM in MB
119
+ ram_mb = rss_kb / 1024
120
+
121
+ # Estimate process age via /proc/<pid>/stat if available
122
+ uptime_h = _get_uptime_hours(pid, now)
123
+
124
+ is_ghost = uptime_h >= GHOST_HOURS
125
+
126
+ processes.append({
127
+ "pid": pid,
128
+ "name": name,
129
+ "command": command[:80],
130
+ "cpu": cpu,
131
+ "ram_mb": ram_mb,
132
+ "tty": tty if tty != "?" else "",
133
+ "start_str": start_str,
134
+ "uptime_h": uptime_h,
135
+ "is_ghost": is_ghost,
136
+ })
137
+
138
+ except (subprocess.TimeoutExpired, OSError):
139
+ pass
140
+
141
+ # Sort: ghosts first, then by RAM desc
142
+ processes.sort(key=lambda p: (-p["is_ghost"], -p["ram_mb"]))
143
+ return processes
144
+
145
+
146
+ _FRIENDLY_NAMES = {
147
+ "opencode-mcp": "OpenCode MCP",
148
+ "opencode": "OpenCode Server",
149
+ "gemini-mcp": "Gemini MCP",
150
+ "server-filesystem": "Filesystem MCP",
151
+ "server-memory": "Memory MCP",
152
+ "server-github": "GitHub MCP",
153
+ "codex": "Codex CLI",
154
+ "mcp-server": "MCP Server",
155
+ }
156
+
157
+
158
+ def _get_cwd_project(pid: int) -> str:
159
+ """Try to read the CWD of a process and extract a project name."""
160
+ try:
161
+ cwd = os.readlink(f"/proc/{pid}/cwd")
162
+ name = short_name_from_path(cwd)
163
+ return "" if name == "Home" else name
164
+ except OSError:
165
+ return ""
166
+
167
+
168
+ def _derive_name(command: str, keyword: str, pid: int = 0) -> str:
169
+ """Derive a friendly display name from the full command string."""
170
+ # Special handling for claude CLI — try to show project context
171
+ if keyword == "claude":
172
+ # Check if resumed session (-r flag)
173
+ resumed = " -r" in command or "\x00-r" in command
174
+ project = _get_cwd_project(pid) if pid else ""
175
+ if project:
176
+ return f"Claude CLI — {project}"
177
+ return "Claude CLI (resumed)" if resumed else "Claude CLI"
178
+
179
+ # Check friendly name table (normalize mcp- prefix for lookup)
180
+ lookup = keyword.removeprefix("mcp-") if keyword not in _FRIENDLY_NAMES else keyword
181
+ if lookup in _FRIENDLY_NAMES:
182
+ return _FRIENDLY_NAMES[lookup]
183
+
184
+ # Fallback: executable basename
185
+ parts = command.split()
186
+ if not parts:
187
+ return keyword
188
+
189
+ exe_base = Path(parts[0]).name
190
+
191
+ name_map = {
192
+ "node": f"node/{keyword}",
193
+ "python3": f"python/{keyword}",
194
+ "python": f"python/{keyword}",
195
+ }
196
+
197
+ if exe_base in name_map:
198
+ return name_map[exe_base]
199
+
200
+ return exe_base if exe_base else keyword
201
+
202
+
203
+ def _get_uptime_hours(pid: int, now: float) -> float:
204
+ """Estimate process uptime in hours via /proc/<pid>/stat."""
205
+ try:
206
+ with open(f"/proc/{pid}/stat") as f:
207
+ stat_data = f.read().split()
208
+ # Field 22 (index 21) is starttime in clock ticks since boot
209
+ starttime_ticks = int(stat_data[21])
210
+ clk_tck = CLK_TCK
211
+
212
+ with open("/proc/uptime") as f:
213
+ boot_seconds_ago = float(f.read().split()[0])
214
+
215
+ # Process start time as seconds since epoch
216
+ process_start = now - boot_seconds_ago + (starttime_ticks / clk_tck)
217
+ uptime_seconds = now - process_start
218
+ return max(0.0, uptime_seconds / 3600)
219
+ except (OSError, ValueError, IndexError):
220
+ return 0.0
221
+
222
+
223
+ # ---------------------------------------------------------------------------
224
+ # Summary stats
225
+ # ---------------------------------------------------------------------------
226
+
227
+ def _compute_stats(processes: list[dict]) -> str:
228
+ total = len(processes)
229
+ total_ram = sum(p["ram_mb"] for p in processes)
230
+ ghosts = sum(1 for p in processes if p["is_ghost"])
231
+ return (
232
+ f"{total} Prozesse | RAM: {total_ram:.0f} MB | "
233
+ f"Ghosts (>{GHOST_HOURS}h): {ghosts}"
234
+ )
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Kill helpers
239
+ # ---------------------------------------------------------------------------
240
+
241
+ def _kill_process(pid: int, parent_widget: Gtk.Widget) -> bool:
242
+ """Send SIGTERM to pid. Returns True if successful."""
243
+ try:
244
+ os.kill(pid, signal.SIGTERM)
245
+ return True
246
+ except ProcessLookupError:
247
+ _show_error(parent_widget, f"Prozess {pid} nicht gefunden (bereits beendet?)")
248
+ return False
249
+ except PermissionError:
250
+ _show_error(parent_widget, f"Keine Berechtigung, Prozess {pid} zu beenden.")
251
+ return False
252
+ except OSError as e:
253
+ _show_error(parent_widget, f"Fehler beim Beenden von {pid}: {e}")
254
+ return False
255
+
256
+
257
+ def _show_error(parent: Gtk.Widget, msg: str) -> None:
258
+ """Show a simple error dialog."""
259
+ toplevel = parent.get_toplevel()
260
+ dialog = Gtk.MessageDialog(
261
+ transient_for=toplevel if isinstance(toplevel, Gtk.Window) else None,
262
+ flags=0,
263
+ message_type=Gtk.MessageType.ERROR,
264
+ buttons=Gtk.ButtonsType.OK,
265
+ text=msg,
266
+ )
267
+ dialog.run()
268
+ dialog.destroy()
269
+
270
+
271
+ def _confirm_kill(parent: Gtk.Widget, pid: int, name: str) -> bool:
272
+ """Show confirmation dialog. Returns True if user confirmed."""
273
+ toplevel = parent.get_toplevel()
274
+ dialog = Gtk.MessageDialog(
275
+ transient_for=toplevel if isinstance(toplevel, Gtk.Window) else None,
276
+ flags=0,
277
+ message_type=Gtk.MessageType.WARNING,
278
+ buttons=Gtk.ButtonsType.YES_NO,
279
+ text=f"Prozess beenden?",
280
+ )
281
+ dialog.format_secondary_text(f"PID {pid} — {name}\n\nSignal: SIGTERM")
282
+ response = dialog.run()
283
+ dialog.destroy()
284
+ return response == Gtk.ResponseType.YES
285
+
286
+
287
+ def _confirm_kill_ghosts(parent: Gtk.Widget, count: int) -> bool:
288
+ """Show confirmation dialog for killing all ghost processes."""
289
+ toplevel = parent.get_toplevel()
290
+ dialog = Gtk.MessageDialog(
291
+ transient_for=toplevel if isinstance(toplevel, Gtk.Window) else None,
292
+ flags=0,
293
+ message_type=Gtk.MessageType.WARNING,
294
+ buttons=Gtk.ButtonsType.YES_NO,
295
+ text=f"Alle Ghosts beenden?",
296
+ )
297
+ dialog.format_secondary_text(
298
+ f"{count} Prozesse aelter als {GHOST_HOURS}h werden per SIGTERM beendet."
299
+ )
300
+ response = dialog.run()
301
+ dialog.destroy()
302
+ return response == Gtk.ResponseType.YES
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # Row builder
307
+ # ---------------------------------------------------------------------------
308
+
309
+ def _ram_color(ram_mb: float) -> str:
310
+ """Return theme-aware color based on RAM usage."""
311
+ p = get_palette()
312
+ if ram_mb < 100:
313
+ return p["green"]
314
+ elif ram_mb < 300:
315
+ return p["peach"]
316
+ else:
317
+ return p["red"]
318
+
319
+
320
+ def _format_uptime(hours: float) -> str:
321
+ if hours < 1:
322
+ return f"{int(hours * 60)}m"
323
+ elif hours < 24:
324
+ return f"{hours:.1f}h"
325
+ else:
326
+ days = int(hours // 24)
327
+ remaining_h = int(hours % 24)
328
+ return f"{days}d {remaining_h}h"
329
+
330
+
331
+ def _build_process_row(proc: dict, parent_widget: Gtk.Widget) -> Gtk.ListBoxRow:
332
+ """Build a single styled ListBoxRow for one process."""
333
+ row = Gtk.ListBoxRow()
334
+ row.set_name("process-row")
335
+
336
+ # Outer box with padding
337
+ outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
338
+ outer.set_margin_top(6)
339
+ outer.set_margin_bottom(6)
340
+ outer.set_margin_start(10)
341
+ outer.set_margin_end(10)
342
+ row.add(outer)
343
+
344
+ # Left: name + command stacked vertically
345
+ info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
346
+ info_box.set_hexpand(True)
347
+ outer.pack_start(info_box, True, True, 0)
348
+
349
+ # Process name — bold, ghost shown in red
350
+ name_label = Gtk.Label(label=proc["name"])
351
+ name_label.set_halign(Gtk.Align.START)
352
+ name_label.set_ellipsize(Pango.EllipsizeMode.END)
353
+ name_label.set_max_width_chars(35)
354
+ name_attrs = Pango.AttrList()
355
+ name_attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
356
+ if proc["is_ghost"]:
357
+ # Red tint for ghost processes (theme-aware)
358
+ r, g, b = hex_to_pango_rgb(get_palette()["red"])
359
+ name_attrs.insert(Pango.attr_foreground_new(r, g, b))
360
+ name_label.set_attributes(name_attrs)
361
+ info_box.pack_start(name_label, False, False, 0)
362
+
363
+ # Command preview — dimmed, smaller
364
+ cmd_label = Gtk.Label(label=proc["command"])
365
+ cmd_label.set_halign(Gtk.Align.START)
366
+ cmd_label.set_ellipsize(Pango.EllipsizeMode.END)
367
+ cmd_label.set_max_width_chars(50)
368
+ cmd_label.get_style_context().add_class("stat-label")
369
+ cmd_attrs = Pango.AttrList()
370
+ cmd_attrs.insert(Pango.attr_scale_new(0.82))
371
+ cmd_label.set_attributes(cmd_attrs)
372
+ info_box.pack_start(cmd_label, False, False, 0)
373
+
374
+ # Middle: PID + uptime
375
+ pid_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
376
+ pid_box.set_valign(Gtk.Align.CENTER)
377
+ outer.pack_start(pid_box, False, False, 0)
378
+
379
+ pid_text = f"PID {proc['pid']}"
380
+ if proc.get("tty"):
381
+ pid_text += f" {proc['tty']}"
382
+ pid_label = Gtk.Label(label=pid_text)
383
+ pid_label.get_style_context().add_class("stat-label")
384
+ pid_attrs = Pango.AttrList()
385
+ pid_attrs.insert(Pango.attr_scale_new(0.85))
386
+ pid_label.set_attributes(pid_attrs)
387
+ pid_box.pack_start(pid_label, False, False, 0)
388
+
389
+ uptime_label = Gtk.Label(label=_format_uptime(proc["uptime_h"]))
390
+ uptime_label.get_style_context().add_class("stat-label")
391
+ uptime_attrs = Pango.AttrList()
392
+ uptime_attrs.insert(Pango.attr_scale_new(0.82))
393
+ uptime_label.set_attributes(uptime_attrs)
394
+ pid_box.pack_start(uptime_label, False, False, 0)
395
+
396
+ # Right: RAM + CPU + Kill button
397
+ meta_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
398
+ meta_box.set_valign(Gtk.Align.CENTER)
399
+ outer.pack_start(meta_box, False, False, 0)
400
+
401
+ # RAM + CPU on one line
402
+ stats_line = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
403
+ meta_box.pack_start(stats_line, False, False, 0)
404
+
405
+ ram_label = Gtk.Label(label=f"{proc['ram_mb']:.0f} MB")
406
+ ram_attrs = Pango.AttrList()
407
+ ram_attrs.insert(Pango.attr_scale_new(0.85))
408
+ # Color code RAM
409
+ r, g, b = hex_to_pango_rgb(_ram_color(proc["ram_mb"]))
410
+ ram_attrs.insert(Pango.attr_foreground_new(r, g, b))
411
+ ram_label.set_attributes(ram_attrs)
412
+ stats_line.pack_start(ram_label, False, False, 0)
413
+
414
+ cpu_label = Gtk.Label(label=f"CPU {proc['cpu']:.1f}%")
415
+ cpu_label.get_style_context().add_class("stat-label")
416
+ cpu_attrs = Pango.AttrList()
417
+ cpu_attrs.insert(Pango.attr_scale_new(0.85))
418
+ cpu_label.set_attributes(cpu_attrs)
419
+ stats_line.pack_start(cpu_label, False, False, 0)
420
+
421
+ # Kill button
422
+ kill_btn = Gtk.Button(label="Kill")
423
+ kill_btn.get_style_context().add_class("destructive-action")
424
+ kill_btn.set_tooltip_text(f"SIGTERM an PID {proc['pid']} senden")
425
+ pid = proc["pid"]
426
+ name = proc["name"]
427
+
428
+ def on_kill_clicked(_btn: Gtk.Button, _pid: int = pid, _name: str = name) -> None:
429
+ if _confirm_kill(_btn, _pid, _name):
430
+ _kill_process(_pid, _btn)
431
+ idle_once(refresh_processes)
432
+
433
+ kill_btn.connect("clicked", on_kill_clicked)
434
+ meta_box.pack_start(kill_btn, False, False, 0)
435
+
436
+ row._process_data = proc # type: ignore[attr-defined]
437
+ row.show_all()
438
+ return row
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Populate / refresh the list
443
+ # ---------------------------------------------------------------------------
444
+
445
+ def _populate_list_box(processes: list[dict]) -> bool:
446
+ """Clear and re-populate the ListBox. Returns False (idle_add one-shot)."""
447
+ global _all_processes
448
+ _all_processes = processes
449
+
450
+ if _list_box is None:
451
+ return False
452
+
453
+ for child in _list_box.get_children():
454
+ _list_box.remove(child)
455
+
456
+ if not processes:
457
+ empty_label = Gtk.Label(label="Keine Claude-relevanten Prozesse gefunden.")
458
+ empty_label.get_style_context().add_class("stat-label")
459
+ empty_label.set_margin_top(20)
460
+ _list_box.add(empty_label)
461
+ else:
462
+ for proc in processes:
463
+ row = _build_process_row(proc, _list_box)
464
+ _list_box.add(row)
465
+
466
+ _list_box.show_all()
467
+
468
+ if _stats_label is not None:
469
+ _stats_label.set_text(_compute_stats(processes))
470
+
471
+ return False # one-shot for idle_add
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Public API
476
+ # ---------------------------------------------------------------------------
477
+
478
+ _refresh_in_flight = False
479
+ _refresh_lock = threading.Lock()
480
+
481
+
482
+ def _refresh_processes_thread() -> None:
483
+ """Scan processes in background thread, push result to GTK via idle_add."""
484
+ global _refresh_in_flight
485
+ try:
486
+ processes = _scan_processes()
487
+ GLib.idle_add(_populate_list_box, processes)
488
+ except Exception:
489
+ log.exception("refresh processes (background)")
490
+ finally:
491
+ with _refresh_lock:
492
+ _refresh_in_flight = False
493
+
494
+
495
+ def refresh_processes() -> bool:
496
+ """Re-scan processes in background thread. Returns True to keep GLib timer alive."""
497
+ global _refresh_in_flight
498
+ with _refresh_lock:
499
+ if _refresh_in_flight:
500
+ return True
501
+ _refresh_in_flight = True
502
+ threading.Thread(
503
+ target=_refresh_processes_thread,
504
+ daemon=True,
505
+ name="claude-panel-process-scan",
506
+ ).start()
507
+ return True # keep timer running
508
+
509
+
510
+ def build_processes_tab() -> Gtk.ScrolledWindow:
511
+ """Build and return the Processes tab widget (Gtk.ScrolledWindow)."""
512
+ global _list_box, _stats_label
513
+
514
+ # Root: ScrolledWindow -> main VBox
515
+ scrolled = Gtk.ScrolledWindow()
516
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
517
+ scrolled.set_hexpand(True)
518
+ scrolled.set_vexpand(True)
519
+
520
+ main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
521
+ main_vbox.set_margin_top(8)
522
+ main_vbox.set_margin_bottom(8)
523
+ main_vbox.set_margin_start(8)
524
+ main_vbox.set_margin_end(8)
525
+ scrolled.add(main_vbox)
526
+
527
+ # Toolbar: title + Kill All Ghosts + refresh button
528
+ toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
529
+ toolbar.set_margin_bottom(6)
530
+ main_vbox.pack_start(toolbar, False, False, 0)
531
+
532
+ title_label = Gtk.Label(label="Prozesse")
533
+ title_label.get_style_context().add_class("section-title")
534
+ title_attrs = Pango.AttrList()
535
+ title_attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
536
+ title_attrs.insert(Pango.attr_scale_new(1.1))
537
+ title_label.set_attributes(title_attrs)
538
+ title_label.set_halign(Gtk.Align.START)
539
+ toolbar.pack_start(title_label, False, False, 0)
540
+
541
+ # Spacer
542
+ spacer = Gtk.Box()
543
+ spacer.set_hexpand(True)
544
+ toolbar.pack_start(spacer, True, True, 0)
545
+
546
+ # Kill All Ghosts button
547
+ kill_ghosts_btn = Gtk.Button(label=f"Kill All Ghosts (>{GHOST_HOURS}h)")
548
+ kill_ghosts_btn.get_style_context().add_class("destructive-action")
549
+ kill_ghosts_btn.set_tooltip_text(f"Alle Prozesse aelter als {GHOST_HOURS}h per SIGTERM beenden")
550
+
551
+ def on_kill_ghosts_clicked(_btn: Gtk.Button) -> None:
552
+ ghosts = [p for p in _all_processes if p["is_ghost"]]
553
+ if not ghosts:
554
+ return
555
+ if _confirm_kill_ghosts(_btn, len(ghosts)):
556
+ for p in ghosts:
557
+ _kill_process(p["pid"], _btn)
558
+ idle_once(refresh_processes)
559
+
560
+ kill_ghosts_btn.connect("clicked", on_kill_ghosts_clicked)
561
+ toolbar.pack_start(kill_ghosts_btn, False, False, 0)
562
+
563
+ # Refresh button
564
+ refresh_btn = Gtk.Button()
565
+ refresh_icon = Gtk.Image.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON)
566
+ refresh_btn.add(refresh_icon)
567
+ refresh_btn.set_tooltip_text("Prozessliste aktualisieren")
568
+ refresh_btn.connect("clicked", lambda _b: refresh_processes())
569
+ toolbar.pack_start(refresh_btn, False, False, 0)
570
+
571
+ # Stats bar
572
+ stats_frame = Gtk.Frame()
573
+ stats_frame.set_margin_bottom(8)
574
+ main_vbox.pack_start(stats_frame, False, False, 0)
575
+
576
+ _stats_label = Gtk.Label(label="Lade Prozesse...")
577
+ _stats_label.get_style_context().add_class("stat-label")
578
+ _stats_label.set_margin_top(6)
579
+ _stats_label.set_margin_bottom(6)
580
+ _stats_label.set_margin_start(10)
581
+ _stats_label.set_margin_end(10)
582
+ _stats_label.set_halign(Gtk.Align.START)
583
+ stats_frame.add(_stats_label)
584
+
585
+ # Column headers
586
+ header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
587
+ header_box.set_margin_start(10)
588
+ header_box.set_margin_end(10)
589
+ header_box.set_margin_bottom(4)
590
+ main_vbox.pack_start(header_box, False, False, 0)
591
+
592
+ for col_label, expand in [("Name / Befehl", True), ("PID / Uptime", False), ("RAM / CPU / Kill", False)]:
593
+ lbl = Gtk.Label(label=col_label)
594
+ lbl.get_style_context().add_class("stat-label")
595
+ col_attrs = Pango.AttrList()
596
+ col_attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
597
+ col_attrs.insert(Pango.attr_scale_new(0.85))
598
+ lbl.set_attributes(col_attrs)
599
+ lbl.set_halign(Gtk.Align.START)
600
+ header_box.pack_start(lbl, expand, expand, 0)
601
+
602
+ # ListBox for processes
603
+ _list_box = Gtk.ListBox()
604
+ _list_box.set_selection_mode(Gtk.SelectionMode.NONE)
605
+ _list_box.get_style_context().add_class("view")
606
+ main_vbox.pack_start(_list_box, True, True, 0)
607
+
608
+ idle_once(lambda: _populate_list_box(_scan_processes()))
609
+
610
+ scrolled.show_all()
611
+ return scrolled
project_dashboard_tab.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Project Dashboard Tab — WebKit2 WebView embedding the Project Dashboard.
3
+
4
+ Provides build_project_dashboard_tab() returning a Gtk.Box with:
5
+ - Toolbar: Reload, Open in Browser, Rescan, server status
6
+ - WebKit2 WebView loading http://localhost:5222
7
+ - Auto-starts uvicorn server if not running
8
+ """
9
+
10
+ import subprocess
11
+ import os
12
+ from pathlib import Path
13
+
14
+ LOG_DIR = Path.home() / ".cache" / "project-dashboard"
15
+ LOG_FILE = LOG_DIR / "server.log"
16
+
17
+ import gi
18
+ gi.require_version("Gtk", "3.0")
19
+ gi.require_version("WebKit2", "4.1")
20
+ from gi.repository import Gtk, GLib, WebKit2
21
+ from theme import get_palette
22
+
23
+ DASHBOARD_PORT = 5222
24
+ DASHBOARD_URL = f"http://localhost:{DASHBOARD_PORT}"
25
+ DASHBOARD_DIR = Path.home() / "Projekte" / "ProjectDashboard"
26
+ VENV_PYTHON = DASHBOARD_DIR / ".venv" / "bin" / "python3"
27
+
28
+ # Module-level refs for refresh
29
+ _webview = None
30
+ _status_label = None
31
+ _spinner = None
32
+ _server_proc = None
33
+ _retry_count = 0
34
+
35
+
36
+ def _server_healthy(port: int) -> bool:
37
+ """Check if the dashboard server responds."""
38
+ try:
39
+ import urllib.request
40
+ with urllib.request.urlopen(
41
+ f"http://127.0.0.1:{port}/api/stats", timeout=2
42
+ ) as resp:
43
+ return resp.status == 200
44
+ except Exception:
45
+ return False
46
+
47
+
48
+ def _ensure_server():
49
+ """Start the uvicorn dashboard server if not already running."""
50
+ global _server_proc
51
+ if _server_healthy(DASHBOARD_PORT):
52
+ return True
53
+
54
+ if not DASHBOARD_DIR.exists():
55
+ return False
56
+
57
+ python = str(VENV_PYTHON) if VENV_PYTHON.exists() else "python3"
58
+ main_py = DASHBOARD_DIR / "main.py"
59
+ if not main_py.exists():
60
+ return False
61
+
62
+ try:
63
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
64
+ log_fh = open(LOG_FILE, "a", encoding="utf-8")
65
+ _server_proc = subprocess.Popen(
66
+ [python, "-m", "uvicorn", "main:app",
67
+ "--host", "127.0.0.1", "--port", str(DASHBOARD_PORT)],
68
+ cwd=str(DASHBOARD_DIR),
69
+ stdout=log_fh,
70
+ stderr=log_fh,
71
+ preexec_fn=os.setpgrp,
72
+ )
73
+ return True
74
+ except Exception:
75
+ return False
76
+
77
+
78
+ def _update_status(connected: bool):
79
+ """Update the status label and spinner."""
80
+ if _status_label is None:
81
+ return
82
+ ctx = _status_label.get_style_context()
83
+ ctx.remove_class("swarm-status-connected")
84
+ ctx.remove_class("swarm-status-disconnected")
85
+ if connected:
86
+ ctx.add_class("swarm-status-connected")
87
+ _status_label.set_text("● Verbunden")
88
+ if _spinner is not None:
89
+ _spinner.stop()
90
+ else:
91
+ ctx.add_class("swarm-status-disconnected")
92
+ _status_label.set_text("● Getrennt")
93
+ if _spinner is not None:
94
+ _spinner.start()
95
+
96
+
97
+ def build_project_dashboard_tab() -> Gtk.Box:
98
+ """Build and return the Project Dashboard tab widget."""
99
+ global _webview, _status_label, _spinner
100
+
101
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
102
+
103
+ # --- Toolbar ---
104
+ toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
105
+ toolbar.set_margin_start(8)
106
+ toolbar.set_margin_end(8)
107
+ toolbar.set_margin_top(6)
108
+ toolbar.set_margin_bottom(4)
109
+ toolbar.get_style_context().add_class("swarm-toolbar")
110
+
111
+ reload_btn = Gtk.Button(label="⟳ Reload")
112
+ reload_btn.set_tooltip_text("Dashboard neu laden")
113
+ reload_btn.connect("clicked", _on_reload)
114
+ reload_btn.get_style_context().add_class("swarm-btn")
115
+ toolbar.pack_start(reload_btn, False, False, 0)
116
+
117
+ browser_btn = Gtk.Button(label="↗ Browser")
118
+ browser_btn.set_tooltip_text("Im Browser oeffnen")
119
+ browser_btn.connect("clicked", _on_open_browser)
120
+ browser_btn.get_style_context().add_class("swarm-btn")
121
+ toolbar.pack_start(browser_btn, False, False, 0)
122
+
123
+ rescan_btn = Gtk.Button(label="⟳ Rescan")
124
+ rescan_btn.set_tooltip_text("Alle Projekte neu scannen")
125
+ rescan_btn.connect("clicked", _on_rescan)
126
+ rescan_btn.get_style_context().add_class("swarm-btn")
127
+ toolbar.pack_start(rescan_btn, False, False, 0)
128
+
129
+ _spinner = Gtk.Spinner()
130
+ toolbar.pack_end(_spinner, False, False, 0)
131
+
132
+ _status_label = Gtk.Label()
133
+ _status_label.set_xalign(1.0)
134
+ toolbar.pack_end(_status_label, False, False, 4)
135
+
136
+ url_label = Gtk.Label(label=DASHBOARD_URL)
137
+ url_label.get_style_context().add_class("swarm-url")
138
+ url_label.set_xalign(1.0)
139
+ toolbar.pack_end(url_label, False, False, 8)
140
+
141
+ box.pack_start(toolbar, False, False, 0)
142
+
143
+ # --- Separator ---
144
+ box.pack_start(Gtk.Separator(), False, False, 0)
145
+
146
+ # --- WebView ---
147
+ _webview = WebKit2.WebView()
148
+
149
+ settings = _webview.get_settings()
150
+ settings.set_enable_javascript(True)
151
+ settings.set_enable_developer_extras(False)
152
+ settings.set_hardware_acceleration_policy(
153
+ WebKit2.HardwareAccelerationPolicy.NEVER
154
+ )
155
+
156
+ # Start server and load
157
+ _ensure_server()
158
+
159
+ if _server_healthy(DASHBOARD_PORT):
160
+ _webview.load_uri(DASHBOARD_URL)
161
+ _update_status(True)
162
+ else:
163
+ _update_status(False)
164
+ p = get_palette()
165
+ _webview.load_html(
166
+ f'<html><body style="background:{p["bg"]};color:{p["text"]};font-family:system-ui;'
167
+ 'display:flex;align-items:center;justify-content:center;height:100vh;'
168
+ 'flex-direction:column;gap:12px">'
169
+ '<div style="font-size:24px;opacity:0.4">⟳</div>'
170
+ '<div>Project Dashboard startet...</div>'
171
+ f'<div style="font-size:12px;color:{p["overlay"]}">Automatischer Retry alle 2s</div>'
172
+ '</body></html>',
173
+ None
174
+ )
175
+ GLib.timeout_add_seconds(2, _retry_load)
176
+
177
+ box.pack_start(_webview, True, True, 0)
178
+
179
+ return box
180
+
181
+
182
+ def _retry_load() -> bool:
183
+ """Retry loading the dashboard URL."""
184
+ global _retry_count
185
+ _retry_count += 1
186
+ if _server_healthy(DASHBOARD_PORT):
187
+ _retry_count = 0
188
+ if _webview:
189
+ _webview.load_uri(DASHBOARD_URL)
190
+ _update_status(True)
191
+ return False
192
+ elif _retry_count >= 15:
193
+ _retry_count = 0
194
+ if _webview:
195
+ log_hint = str(LOG_FILE)
196
+ p = get_palette()
197
+ _webview.load_html(
198
+ f'<html><body style="background:{p["bg"]};color:{p["red"]};font-family:system-ui;'
199
+ 'display:flex;align-items:center;justify-content:center;height:100vh;'
200
+ 'flex-direction:column;gap:12px">'
201
+ '<div style="font-size:24px">Server-Fehler</div>'
202
+ f'<div style="font-size:12px;color:{p["overlay"]}">Log: {log_hint}</div>'
203
+ f'<button onclick="location.reload()" style="background:{p["card"]};color:{p["text"]};'
204
+ f'border:1px solid {p["border"]};border-radius:6px;padding:8px 16px;cursor:pointer;'
205
+ 'font-size:13px;margin-top:8px">Retry</button>'
206
+ '</body></html>',
207
+ None
208
+ )
209
+ _update_status(False)
210
+ return False
211
+ else:
212
+ _ensure_server()
213
+ return True
214
+
215
+
216
+ def _on_reload(_btn):
217
+ """Reload the WebView."""
218
+ if _webview:
219
+ _webview.reload()
220
+
221
+
222
+ def _on_open_browser(_btn):
223
+ """Open dashboard in default browser."""
224
+ try:
225
+ subprocess.Popen(
226
+ ["xdg-open", DASHBOARD_URL],
227
+ stdout=subprocess.DEVNULL,
228
+ stderr=subprocess.DEVNULL,
229
+ )
230
+ except Exception:
231
+ pass
232
+
233
+
234
+ def _on_rescan(_btn):
235
+ """Trigger a rescan via the API and reload."""
236
+ try:
237
+ import urllib.request
238
+ req = urllib.request.Request(
239
+ f"http://127.0.0.1:{DASHBOARD_PORT}/rescan", method="POST"
240
+ )
241
+ urllib.request.urlopen(req, timeout=10)
242
+ except Exception:
243
+ pass
244
+ if _webview:
245
+ _webview.reload()
246
+
247
+
248
+ def refresh_project_dashboard() -> bool:
249
+ """Check server health — called by GLib timer from panel.py."""
250
+ connected = _server_healthy(DASHBOARD_PORT)
251
+ _update_status(connected)
252
+ if not connected:
253
+ _ensure_server()
254
+ return True # keep timer alive
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ PyGObject>=3.42
session_browser.py ADDED
@@ -0,0 +1,1039 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Session Browser — GTK3 widget module for Claude Code Control Panel.
3
+
4
+ Provides build_sessions_tab() returning a Gtk.ScrolledWindow with:
5
+ - Full session list from ~/.claude/projects/ (last 20 by mtime)
6
+ - Search/filter by project name or preview text
7
+ - Per-session resume button (kitty -e claude -r SESSION_ID)
8
+ - Stats bar: total sessions, sessions in last hour, unique projects
9
+ - Refresh button to re-scan
10
+
11
+ Theme: Catppuccin Mocha (dark) / Latte (light) via theme.py
12
+ """
13
+
14
+ import html as html_mod
15
+ import json
16
+ import logging
17
+ import re
18
+ import shlex
19
+ import subprocess
20
+ import threading
21
+ import time
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+
25
+ log = logging.getLogger("claude_panel.session_browser")
26
+
27
+ # Module-level metadata cache: path → (mtime, meta_dict)
28
+ _meta_cache: dict[str, tuple[float, dict]] = {}
29
+
30
+ import gi
31
+
32
+ gi.require_version("Gtk", "3.0")
33
+ from gi.repository import GLib, Gtk, Pango
34
+
35
+ from utils import idle_once, short_name_from_path, HOME
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Constants
39
+ # ---------------------------------------------------------------------------
40
+ PROJECTS_DIR = Path(HOME) / ".claude" / "projects"
41
+
42
+ # Colors are handled by CSS classes defined in theme.py
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Module-level state (kept between refreshes)
46
+ # Grouped into a single namespace dict to avoid scattered globals.
47
+ # ---------------------------------------------------------------------------
48
+ _state: dict = {
49
+ "list_box": None, # Gtk.ListBox | None
50
+ "stats_label": None, # Gtk.Label | None
51
+ "search_entry": None, # Gtk.SearchEntry | None
52
+ "all_sessions": [], # list[dict]
53
+ "search_mode": False, # True when in grep search mode
54
+ "debounce_id": None, # GLib timeout source ID for debounce
55
+ "batch_id": None, # GLib idle source ID for batch insertion
56
+ }
57
+
58
+ # Cancellation token for the background grep thread.
59
+ # Replaced with a fresh Event each time a new search starts.
60
+ _search_cancel: threading.Event = threading.Event()
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Session scanning helpers
65
+ # ---------------------------------------------------------------------------
66
+
67
+ def _format_size(size_bytes: int) -> str:
68
+ if size_bytes < 1024:
69
+ return f"{size_bytes} B"
70
+ elif size_bytes < 1024 * 1024:
71
+ return f"{size_bytes / 1024:.1f} KB"
72
+ else:
73
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
74
+
75
+
76
+ _PREVIEW_SKIP_PREFIXES = (
77
+ "[Request interrupted",
78
+ "Implement the following plan",
79
+ "<local-command-caveat>",
80
+ "<command-name>",
81
+ )
82
+
83
+
84
+ def _get_session_meta(path: Path, max_len: int = 120) -> dict:
85
+ """Read metadata from JSONL file: preview text, cwd, slug.
86
+
87
+ Uses a module-level mtime cache — unchanged files are never re-read.
88
+ For files under 512KB: reads all lines ONCE, extracts first 30 + last 10.
89
+ For large files (>=512KB): reads first 30 lines + subprocess tail for last 10.
90
+ """
91
+ path_str = str(path)
92
+ try:
93
+ stat = path.stat()
94
+ mtime = stat.st_mtime
95
+ file_size = stat.st_size
96
+ except OSError:
97
+ return {"preview": "(keine Vorschau)", "cwd": "", "slug": ""}
98
+
99
+ # Cache hit — file unchanged since last read
100
+ cached = _meta_cache.get(path_str)
101
+ if cached is not None and cached[0] == mtime:
102
+ return cached[1]
103
+
104
+ slug: str = ""
105
+ cwd: str = ""
106
+ preview: str = ""
107
+
108
+ try:
109
+ if file_size < 512 * 1024:
110
+ # Single read — extract head and tail from the same list
111
+ with path.open(encoding="utf-8", errors="replace") as f:
112
+ all_lines = f.readlines()
113
+ head_lines = all_lines[:30]
114
+ tail_lines = all_lines[-10:] if len(all_lines) > 30 else []
115
+ else:
116
+ # Large file: read first 30 lines + subprocess tail
117
+ head_lines = []
118
+ with path.open(encoding="utf-8", errors="replace") as f:
119
+ for _ in range(30):
120
+ line = f.readline()
121
+ if not line:
122
+ break
123
+ head_lines.append(line)
124
+ try:
125
+ result = subprocess.run(
126
+ ["tail", "-n", "10", str(path)],
127
+ capture_output=True, text=True, timeout=2
128
+ )
129
+ tail_lines = result.stdout.splitlines(keepends=True)
130
+ except (subprocess.TimeoutExpired, OSError):
131
+ tail_lines = []
132
+
133
+ all_sample = head_lines + tail_lines
134
+
135
+ for line in all_sample:
136
+ line = line.strip()
137
+ if not line:
138
+ continue
139
+ try:
140
+ entry = json.loads(line)
141
+ except json.JSONDecodeError:
142
+ continue
143
+
144
+ if not slug and entry.get("slug"):
145
+ slug = entry["slug"]
146
+ if not cwd and entry.get("cwd"):
147
+ cwd = entry["cwd"]
148
+
149
+ if preview:
150
+ if cwd and slug:
151
+ break
152
+ continue
153
+
154
+ if entry.get("type") != "user":
155
+ continue
156
+
157
+ msg = entry.get("message", {})
158
+ content = msg.get("content", "")
159
+ if isinstance(content, list):
160
+ text_blocks = [
161
+ block.get("text", "")
162
+ for block in content
163
+ if isinstance(block, dict)
164
+ and block.get("type") == "text"
165
+ and "<local-command-caveat>" not in block.get("text", "")
166
+ ]
167
+ if text_blocks:
168
+ content = text_blocks[0]
169
+ else:
170
+ continue
171
+ if not isinstance(content, str):
172
+ continue
173
+ content = content.strip()
174
+ if any(content.startswith(p) for p in _PREVIEW_SKIP_PREFIXES):
175
+ if content.startswith("Implement the following plan"):
176
+ for plan_line in content.split("\n"):
177
+ plan_line = plan_line.strip()
178
+ if plan_line.startswith("# "):
179
+ title = plan_line[2:].strip()
180
+ if title.lower().startswith("plan:"):
181
+ title = title[5:].strip()
182
+ if len(title) > max_len:
183
+ title = title[:max_len] + "..."
184
+ preview = title
185
+ break
186
+ continue
187
+ first_line = content.split("\n")[0]
188
+ if len(first_line) > max_len:
189
+ first_line = first_line[:max_len] + "..."
190
+ preview = first_line
191
+ except (OSError, PermissionError):
192
+ pass
193
+
194
+ if not preview:
195
+ preview = slug if slug else "(keine Vorschau)"
196
+ result = {"preview": preview, "cwd": cwd, "slug": slug}
197
+ _meta_cache[path_str] = (mtime, result)
198
+ return result
199
+
200
+
201
+ def _get_session_status(path: Path, mtime: float, now: float) -> str:
202
+ """Determine session status from last JSONL entry.
203
+
204
+ Returns:
205
+ "working" — Claude is actively processing (last entry is tool_use or user msg)
206
+ "ready" — Claude finished, waiting for user input
207
+ "idle" — session not recently active
208
+ """
209
+ age = now - mtime
210
+ if age > 120:
211
+ return "idle"
212
+
213
+ # Read last non-empty line of JSONL (seek from end for speed)
214
+ try:
215
+ size = path.stat().st_size
216
+ if size == 0:
217
+ return "idle"
218
+ # Read last 4KB — enough for the final JSONL line
219
+ read_size = min(size, 4096)
220
+ with path.open("rb") as f:
221
+ f.seek(-read_size, 2)
222
+ tail = f.read().decode("utf-8", errors="replace")
223
+ lines = [l for l in tail.strip().splitlines() if l.strip()]
224
+ if not lines:
225
+ return "idle"
226
+ last_line = lines[-1]
227
+ try:
228
+ entry = json.loads(last_line)
229
+ except json.JSONDecodeError:
230
+ # If last line is corrupt, check second-to-last
231
+ if len(lines) >= 2:
232
+ try:
233
+ entry = json.loads(lines[-2])
234
+ except json.JSONDecodeError:
235
+ return "ready" if age < 120 else "idle"
236
+ else:
237
+ return "ready" if age < 120 else "idle"
238
+
239
+ entry_type = entry.get("type", "")
240
+ msg = entry.get("message", {})
241
+
242
+ # User message → Claude is about to process
243
+ if entry_type == "user":
244
+ return "working"
245
+
246
+ # Assistant message → check for tool_use (Claude still working)
247
+ if entry_type == "assistant":
248
+ content = msg.get("content", [])
249
+ if isinstance(content, list):
250
+ for block in content:
251
+ if isinstance(block, dict) and block.get("type") == "tool_use":
252
+ return "working"
253
+ # Check stop_reason — tool_use means Claude wants to call a tool
254
+ stop = msg.get("stop_reason", "")
255
+ if stop == "tool_use":
256
+ return "working"
257
+ # end_turn or max_tokens → Claude is done
258
+ return "ready"
259
+
260
+ # Tool result → Claude is processing
261
+ if entry_type == "tool_result":
262
+ return "working"
263
+
264
+ # Fallback: if very recent mtime, likely working
265
+ return "working" if age < 5 else "ready"
266
+
267
+ except (OSError, PermissionError):
268
+ return "ready" if age < 120 else "idle"
269
+
270
+
271
+ def _build_project_info(path: Path, session_cwd: str) -> tuple[str, str]:
272
+ """Derive (project_name, short_name) from a session's CWD or parent dir name.
273
+
274
+ Shared by _scan_all_sessions() and _grep_sessions() to avoid duplicated logic.
275
+ Returns (project_name, short_name).
276
+ """
277
+ short_name = short_name_from_path(session_cwd)
278
+ if session_cwd and session_cwd != HOME:
279
+ project_name = session_cwd.replace(HOME, "~", 1)
280
+ else:
281
+ # Fallback: derive project from JSONL parent dir name
282
+ # e.g. "-home-smlflg-Projekte-ClaudeCodePanel" → "ClaudeCodePanel"
283
+ dir_name = path.parent.name # encoded path
284
+ parts = dir_name.split("-")
285
+ # Strip leading home-user prefix
286
+ # "-home-smlflg-Projekte-Foo" → ["", "home", "smlflg", "Projekte", "Foo"]
287
+ meaningful = [p for p in parts if p and p not in ("home", "smlflg")]
288
+ if meaningful:
289
+ short_name = meaningful[-1]
290
+ project_name = "~/" + "/".join(meaningful)
291
+ else:
292
+ project_name = "Home"
293
+ return project_name, short_name
294
+
295
+
296
+ def _scan_all_sessions() -> list[dict]:
297
+ """Scan ~/.claude/projects/ and return session dicts sorted by mtime desc.
298
+
299
+ Caps at 200 most-recently-modified JSONL files to bound scan time on
300
+ large installations (2800+ files). Older sessions remain accessible
301
+ via fulltext search (_grep_sessions).
302
+ """
303
+ sessions: list[dict] = []
304
+
305
+ if not PROJECTS_DIR.exists():
306
+ return sessions
307
+
308
+ # Collect all JSONL paths first, then sort by mtime and cap at 200
309
+ all_files: list[Path] = []
310
+ for project_dir in PROJECTS_DIR.iterdir():
311
+ if not project_dir.is_dir():
312
+ continue
313
+ for f in project_dir.glob("*.jsonl"):
314
+ all_files.append(f)
315
+
316
+ # Sort newest-first and limit to 200 — avoids scanning all 2800+ files
317
+ all_files = sorted(all_files, key=lambda p: p.stat().st_mtime, reverse=True)[:200]
318
+
319
+ now = time.time()
320
+ for f in all_files:
321
+ try:
322
+ stat = f.stat()
323
+ meta = _get_session_meta(f)
324
+ project_name, short_name = _build_project_info(f, meta["cwd"])
325
+ status = _get_session_status(f, stat.st_mtime, now)
326
+ sessions.append(
327
+ {
328
+ "path": str(f),
329
+ "project": project_name,
330
+ "short_name": short_name,
331
+ "session_id": f.stem,
332
+ "mtime": stat.st_mtime,
333
+ "size": stat.st_size,
334
+ "time_str": datetime.fromtimestamp(stat.st_mtime).strftime(
335
+ "%d.%m %H:%M"
336
+ ),
337
+ "preview": meta["preview"],
338
+ "slug": meta["slug"],
339
+ "status": status,
340
+ }
341
+ )
342
+ except (OSError, PermissionError):
343
+ continue
344
+
345
+ sessions.sort(key=lambda s: s["mtime"], reverse=True)
346
+ return sessions
347
+
348
+
349
+ def _grep_sessions(query: str) -> list[dict]:
350
+ """Fulltext search across all session files using grep -l -i.
351
+
352
+ Returns matching session dicts with snippet context.
353
+ Runs synchronously (~0.3s for 649 files).
354
+ """
355
+ if not PROJECTS_DIR.exists():
356
+ return []
357
+
358
+ # Collect all JSONL files
359
+ all_files = []
360
+ for project_dir in PROJECTS_DIR.iterdir():
361
+ if not project_dir.is_dir():
362
+ continue
363
+ for f in project_dir.glob("*.jsonl"):
364
+ all_files.append(str(f))
365
+
366
+ if not all_files:
367
+ return []
368
+
369
+ # grep -l -i for matching files
370
+ try:
371
+ result = subprocess.run(
372
+ ["grep", "-l", "-i", "--", query] + all_files,
373
+ capture_output=True, text=True, timeout=10
374
+ )
375
+ matching_files = [f for f in result.stdout.splitlines() if f.strip()]
376
+ except (subprocess.TimeoutExpired, OSError):
377
+ return []
378
+
379
+ if not matching_files:
380
+ return []
381
+
382
+ # Batch grep -c for match counts (one subprocess for all files)
383
+ counts: dict[str, int] = {}
384
+ try:
385
+ count_result = subprocess.run(
386
+ ["grep", "-i", "-c", "--", query] + matching_files,
387
+ capture_output=True, text=True, timeout=10
388
+ )
389
+ for line in count_result.stdout.splitlines():
390
+ # Format: filepath:count
391
+ if ":" in line:
392
+ fpath, _, cnt = line.rpartition(":")
393
+ try:
394
+ counts[fpath] = int(cnt)
395
+ except ValueError:
396
+ pass
397
+ except (subprocess.TimeoutExpired, OSError):
398
+ pass
399
+
400
+ # Build session dicts for matching files
401
+ now = time.time()
402
+ query_lower = query.lower()
403
+ sessions = []
404
+ for filepath in matching_files:
405
+ path = Path(filepath)
406
+ try:
407
+ stat = path.stat()
408
+ meta = _get_session_meta(path)
409
+ project_name, short_name = _build_project_info(path, meta["cwd"])
410
+ status = _get_session_status(path, stat.st_mtime, now)
411
+
412
+ sessions.append({
413
+ "path": filepath,
414
+ "project": project_name,
415
+ "short_name": short_name,
416
+ "session_id": path.stem,
417
+ "mtime": stat.st_mtime,
418
+ "size": stat.st_size,
419
+ "time_str": datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m %H:%M"),
420
+ "preview": meta["preview"],
421
+ "status": status,
422
+ "snippet": "",
423
+ "match_count": counts.get(filepath, 0),
424
+ })
425
+ except (OSError, PermissionError):
426
+ continue
427
+
428
+ sessions.sort(key=lambda s: s["mtime"], reverse=True)
429
+
430
+ # Extract snippets: one batched grep -m1 -H for top 50 results
431
+ top_files = [s["path"] for s in sessions[:50]]
432
+ if top_files:
433
+ snippet_map: dict[str, str] = {}
434
+ try:
435
+ snip_result = subprocess.run(
436
+ ["grep", "-i", "-m", "1", "-H", "--", query] + top_files,
437
+ capture_output=True, text=True, timeout=10
438
+ )
439
+ for line in snip_result.stdout.splitlines():
440
+ # Format: filepath:json_line (split on first : after filepath)
441
+ # JSONL paths contain no colons, so first : is the separator
442
+ sep = line.find(".jsonl:")
443
+ if sep < 0:
444
+ continue
445
+ fpath = line[:sep + 6] # include ".jsonl"
446
+ raw = line[sep + 7:] # skip ":"
447
+ if fpath not in snippet_map:
448
+ snippet_map[fpath] = raw.strip()
449
+ except (subprocess.TimeoutExpired, OSError):
450
+ snippet_map = {}
451
+
452
+ for session in sessions[:50]:
453
+ raw = snippet_map.get(session["path"], "")
454
+ if not raw:
455
+ continue
456
+ try:
457
+ obj = json.loads(raw)
458
+ msg = obj.get("message", {})
459
+ content = msg.get("content", "") if isinstance(msg, dict) else ""
460
+ if isinstance(content, list):
461
+ texts = [b.get("text", "") for b in content
462
+ if isinstance(b, dict) and b.get("type") == "text"]
463
+ content = " ".join(texts)
464
+ if not isinstance(content, str):
465
+ content = ""
466
+ if query_lower not in content.lower():
467
+ content = json.dumps(obj, ensure_ascii=False)
468
+ except (json.JSONDecodeError, AttributeError):
469
+ content = raw
470
+ if query_lower in content.lower():
471
+ idx = content.lower().find(query_lower)
472
+ start = max(0, idx - 60)
473
+ end = min(len(content), idx + len(query) + 60)
474
+ snippet = content[start:end].replace("\n", " ").strip()
475
+ for char in ('"', '{', '}', '\\'):
476
+ snippet = snippet.replace(char, ' ')
477
+ snippet = " ".join(snippet.split())
478
+ if start > 0:
479
+ snippet = "…" + snippet
480
+ if end < len(content):
481
+ snippet = snippet + "…"
482
+ session["snippet"] = snippet
483
+
484
+ return sessions
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Stats helper
489
+ # ---------------------------------------------------------------------------
490
+
491
+ def _compute_stats(sessions: list[dict], now: float | None = None) -> str:
492
+ total = len(sessions)
493
+ if now is None:
494
+ now = time.time()
495
+ working = sum(1 for s in sessions if s.get("status") == "working")
496
+ ready = sum(1 for s in sessions if s.get("status") == "ready")
497
+ recent = sum(1 for s in sessions if now - s["mtime"] < 3600)
498
+ projects = len({s["project"] for s in sessions})
499
+ parts = [f"{total} Sessions"]
500
+ if working:
501
+ parts.append(f"{working} arbeitet")
502
+ if ready:
503
+ parts.append(f"{ready} bereit")
504
+ parts.append(f"{recent} in letzter Stunde")
505
+ parts.append(f"{projects} Projekte")
506
+ return " | ".join(parts)
507
+
508
+
509
+ # ---------------------------------------------------------------------------
510
+ # Row builder
511
+ # ---------------------------------------------------------------------------
512
+
513
+ def _build_session_row(session: dict, now: float | None = None) -> Gtk.ListBoxRow:
514
+ """Build a single styled ListBoxRow for one session."""
515
+ if now is None:
516
+ now = time.time()
517
+ row = Gtk.ListBoxRow()
518
+ row.set_name("session-row")
519
+ row.get_style_context().add_class("session-row")
520
+
521
+ status = session.get("status", "idle")
522
+ is_active = status in ("working", "ready")
523
+ if status == "working":
524
+ row.get_style_context().add_class("session-row-working")
525
+ elif status == "ready":
526
+ row.get_style_context().add_class("session-row-ready")
527
+
528
+ # Outer box
529
+ outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
530
+ outer.set_margin_top(4)
531
+ outer.set_margin_bottom(4)
532
+ outer.set_margin_start(8)
533
+ outer.set_margin_end(10)
534
+ row.add(outer)
535
+
536
+ # Left: short name + preview + full path stacked vertically
537
+ info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
538
+ info_box.set_hexpand(True)
539
+ outer.pack_start(info_box, True, True, 0)
540
+
541
+ # Short project name — bold, dedicated class
542
+ name_label = Gtk.Label(label=session["short_name"])
543
+ name_label.set_halign(Gtk.Align.START)
544
+ name_label.set_ellipsize(Pango.EllipsizeMode.END)
545
+ name_label.set_max_width_chars(45)
546
+ name_label.get_style_context().add_class("session-project")
547
+ info_box.pack_start(name_label, False, False, 0)
548
+
549
+ # Subtitle: slug (e.g. "Wild Crafting Sutton") or first 80 chars of preview
550
+ raw_slug = session.get("slug", "")
551
+ if raw_slug:
552
+ subtitle_text = raw_slug.replace("-", " ").title()
553
+ else:
554
+ raw_preview = session.get("preview", "")
555
+ subtitle_text = raw_preview[:80] + ("…" if len(raw_preview) > 80 else "")
556
+ if subtitle_text:
557
+ subtitle_label = Gtk.Label(label=subtitle_text)
558
+ subtitle_label.set_halign(Gtk.Align.START)
559
+ subtitle_label.set_ellipsize(Pango.EllipsizeMode.END)
560
+ subtitle_label.set_max_width_chars(70)
561
+ subtitle_label.get_style_context().add_class("session-meta")
562
+ subtitle_attrs = Pango.AttrList()
563
+ subtitle_attrs.insert(Pango.attr_scale_new(0.88))
564
+ subtitle_label.set_attributes(subtitle_attrs)
565
+ info_box.pack_start(subtitle_label, False, False, 0)
566
+
567
+ # Preview — Subtext1 for better contrast
568
+ preview_label = Gtk.Label(label=session["preview"])
569
+ preview_label.set_halign(Gtk.Align.START)
570
+ preview_label.set_ellipsize(Pango.EllipsizeMode.END)
571
+ preview_label.set_max_width_chars(90)
572
+ preview_label.get_style_context().add_class("session-preview")
573
+ info_box.pack_start(preview_label, False, False, 0)
574
+
575
+ # Project path — always shown for context
576
+ path_label = Gtk.Label(label=session["project"])
577
+ path_label.set_halign(Gtk.Align.START)
578
+ path_label.set_ellipsize(Pango.EllipsizeMode.END)
579
+ path_label.set_max_width_chars(70)
580
+ path_label.get_style_context().add_class("session-meta")
581
+ path_attrs = Pango.AttrList()
582
+ path_attrs.insert(Pango.attr_scale_new(0.82))
583
+ path_label.set_attributes(path_attrs)
584
+ info_box.pack_start(path_label, False, False, 0)
585
+
586
+ # Right: meta info + resume button
587
+ meta_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
588
+ meta_box.set_valign(Gtk.Align.CENTER)
589
+ outer.pack_start(meta_box, False, False, 0)
590
+
591
+ # Status badge for active sessions
592
+ if status == "working":
593
+ badge = Gtk.Label(label="ARBEITET")
594
+ badge.get_style_context().add_class("session-working-badge")
595
+ badge.set_halign(Gtk.Align.END)
596
+ meta_box.pack_start(badge, False, False, 0)
597
+ elif status == "ready":
598
+ badge = Gtk.Label(label="BEREIT")
599
+ badge.get_style_context().add_class("session-ready-badge")
600
+ badge.set_halign(Gtk.Align.END)
601
+ meta_box.pack_start(badge, False, False, 0)
602
+
603
+ # Date/time + size — dimmed metadata
604
+ meta_line = Gtk.Label(label=f"{session['time_str']} {_format_size(session['size'])}")
605
+ meta_line.set_halign(Gtk.Align.END)
606
+ meta_line.get_style_context().add_class("session-meta")
607
+ meta_box.pack_start(meta_line, False, False, 0)
608
+
609
+ # Resume button — custom styled
610
+ resume_btn = Gtk.Button(label="Resume")
611
+ resume_btn.set_tooltip_text(f"claude -r {session['session_id']}")
612
+ resume_btn.get_style_context().add_class("session-resume")
613
+ session_id = session["session_id"]
614
+ resume_btn.connect("clicked", _on_resume_clicked, session_id)
615
+ meta_box.pack_start(resume_btn, False, False, 0)
616
+
617
+ # Store session data on row for filtering
618
+ row._session_data = session # type: ignore[attr-defined]
619
+
620
+ row.show_all()
621
+ return row
622
+
623
+
624
+ def _build_search_row(session: dict, query: str, now: float | None = None) -> Gtk.ListBoxRow:
625
+ """Build a ListBoxRow for a search result with snippet + highlighting."""
626
+ if now is None:
627
+ now = time.time()
628
+ row = Gtk.ListBoxRow()
629
+ row.set_name("session-row")
630
+ row.get_style_context().add_class("session-row")
631
+ row.get_style_context().add_class("session-row-search")
632
+
633
+ # Outer box
634
+ outer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
635
+ outer.set_margin_top(4)
636
+ outer.set_margin_bottom(4)
637
+ outer.set_margin_start(8)
638
+ outer.set_margin_end(10)
639
+ row.add(outer)
640
+
641
+ # Left: short name + preview + snippet + path
642
+ info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
643
+ info_box.set_hexpand(True)
644
+ outer.pack_start(info_box, True, True, 0)
645
+
646
+ # Short project name
647
+ name_label = Gtk.Label(label=session["short_name"])
648
+ name_label.set_halign(Gtk.Align.START)
649
+ name_label.set_ellipsize(Pango.EllipsizeMode.END)
650
+ name_label.set_max_width_chars(45)
651
+ name_label.get_style_context().add_class("session-project")
652
+ info_box.pack_start(name_label, False, False, 0)
653
+
654
+ # Preview
655
+ preview_label = Gtk.Label(label=session["preview"])
656
+ preview_label.set_halign(Gtk.Align.START)
657
+ preview_label.set_ellipsize(Pango.EllipsizeMode.END)
658
+ preview_label.set_max_width_chars(90)
659
+ preview_label.get_style_context().add_class("session-preview")
660
+ info_box.pack_start(preview_label, False, False, 0)
661
+
662
+ # Snippet with Pango markup highlighting
663
+ snippet = session.get("snippet", "")
664
+ if snippet and query:
665
+ # Escape markup characters first, then highlight
666
+ safe_snippet = html_mod.escape(snippet)
667
+ safe_query = html_mod.escape(query)
668
+ # Case-insensitive highlight
669
+ highlighted = re.sub(
670
+ re.escape(safe_query),
671
+ lambda m: f"<b>{m.group()}</b>",
672
+ safe_snippet,
673
+ flags=re.IGNORECASE
674
+ )
675
+ snippet_label = Gtk.Label()
676
+ snippet_label.set_markup(highlighted)
677
+ snippet_label.set_halign(Gtk.Align.START)
678
+ snippet_label.set_ellipsize(Pango.EllipsizeMode.END)
679
+ snippet_label.set_max_width_chars(100)
680
+ snippet_label.get_style_context().add_class("session-snippet")
681
+ info_box.pack_start(snippet_label, False, False, 0)
682
+
683
+ # Project path
684
+ path_label = Gtk.Label(label=session["project"])
685
+ path_label.set_halign(Gtk.Align.START)
686
+ path_label.set_ellipsize(Pango.EllipsizeMode.END)
687
+ path_label.set_max_width_chars(70)
688
+ path_label.get_style_context().add_class("session-meta")
689
+ path_attrs = Pango.AttrList()
690
+ path_attrs.insert(Pango.attr_scale_new(0.82))
691
+ path_label.set_attributes(path_attrs)
692
+ info_box.pack_start(path_label, False, False, 0)
693
+
694
+ # Right: match badge + meta + resume button
695
+ meta_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
696
+ meta_box.set_valign(Gtk.Align.CENTER)
697
+ outer.pack_start(meta_box, False, False, 0)
698
+
699
+ # Match count badge
700
+ match_count = session.get("match_count", 0)
701
+ if match_count > 0:
702
+ badge = Gtk.Label(label=f"{match_count} Treffer")
703
+ badge.get_style_context().add_class("session-match-badge")
704
+ badge.set_halign(Gtk.Align.END)
705
+ meta_box.pack_start(badge, False, False, 0)
706
+
707
+ # Date/time + size
708
+ meta_line = Gtk.Label(label=f"{session['time_str']} {_format_size(session['size'])}")
709
+ meta_line.set_halign(Gtk.Align.END)
710
+ meta_line.get_style_context().add_class("session-meta")
711
+ meta_box.pack_start(meta_line, False, False, 0)
712
+
713
+ # Resume button
714
+ resume_btn = Gtk.Button(label="Resume")
715
+ resume_btn.set_tooltip_text(f"claude -r {session['session_id']}")
716
+ resume_btn.get_style_context().add_class("session-resume")
717
+ session_id = session["session_id"]
718
+ resume_btn.connect("clicked", _on_resume_clicked, session_id)
719
+ meta_box.pack_start(resume_btn, False, False, 0)
720
+
721
+ row._session_data = session
722
+ row.show_all()
723
+ return row
724
+
725
+
726
+ def _on_resume_clicked(_btn: Gtk.Button, session_id: str) -> None:
727
+ """Launch kitty terminal with claude -r SESSION_ID."""
728
+ try:
729
+ # List-form Popen (not shell=True) is safe against injection regardless of
730
+ # special characters in session_id — each element is passed as a literal argument.
731
+ subprocess.Popen(
732
+ ["kitty", "-e", "claude", "-r", session_id],
733
+ stdout=subprocess.DEVNULL,
734
+ stderr=subprocess.DEVNULL,
735
+ )
736
+ except FileNotFoundError:
737
+ # kitty not found — fallback terminal uses shell=False list form too.
738
+ # shlex.quote is applied to session_id only for the shell=True bash -c string.
739
+ try:
740
+ subprocess.Popen(
741
+ ["x-terminal-emulator", "-e", "bash", "-c", f"claude -r {shlex.quote(session_id)}"],
742
+ stdout=subprocess.DEVNULL,
743
+ stderr=subprocess.DEVNULL,
744
+ )
745
+ except FileNotFoundError:
746
+ pass
747
+
748
+
749
+ # ---------------------------------------------------------------------------
750
+ # Filter function for Gtk.ListBox
751
+ # ---------------------------------------------------------------------------
752
+
753
+ def _filter_func(row: Gtk.ListBoxRow) -> bool:
754
+ """Return True if row matches current search query."""
755
+ if _state["search_mode"]:
756
+ return True # grep already filtered — show all rows
757
+ if _state["search_entry"] is None:
758
+ return True
759
+ query = _state["search_entry"].get_text().strip().lower()
760
+ if not query:
761
+ return True
762
+ session = getattr(row, "_session_data", None)
763
+ if session is None:
764
+ return True
765
+ return (
766
+ query in session.get("short_name", "").lower()
767
+ or query in session["project"].lower()
768
+ or query in session["preview"].lower()
769
+ or query in session["session_id"].lower()
770
+ )
771
+
772
+
773
+ # ---------------------------------------------------------------------------
774
+ # Populate / refresh the list
775
+ # ---------------------------------------------------------------------------
776
+
777
+ def _populate_list_box(sessions: list[dict]) -> None:
778
+ """Clear and re-populate the ListBox with new session rows."""
779
+ # Cancel any running batch insertion from search mode
780
+ if _state["batch_id"] is not None:
781
+ GLib.source_remove(_state["batch_id"])
782
+ _state["batch_id"] = None
783
+
784
+ _state["all_sessions"] = sessions
785
+
786
+ if _state["list_box"] is None:
787
+ return
788
+
789
+ # Remove all existing children
790
+ for child in _state["list_box"].get_children():
791
+ _state["list_box"].remove(child)
792
+
793
+ # Add all rows
794
+ now = time.time()
795
+ for session in sessions:
796
+ row = _build_session_row(session, now)
797
+ _state["list_box"].add(row)
798
+
799
+ _state["list_box"].show_all()
800
+
801
+ # Update stats bar
802
+ if _state["stats_label"] is not None:
803
+ _state["stats_label"].set_text(_compute_stats(sessions, now))
804
+
805
+
806
+ # ---------------------------------------------------------------------------
807
+ # Search logic (debounced, two-mode)
808
+ # ---------------------------------------------------------------------------
809
+
810
+ def _on_search_changed(_entry: Gtk.SearchEntry) -> None:
811
+ """Handle search text changes with 300ms debounce."""
812
+ # Cancel previous debounce timer
813
+ if _state["debounce_id"] is not None:
814
+ GLib.source_remove(_state["debounce_id"])
815
+ _state["debounce_id"] = None
816
+
817
+ # Cancel any running batch insertion
818
+ if _state["batch_id"] is not None:
819
+ GLib.source_remove(_state["batch_id"])
820
+ _state["batch_id"] = None
821
+
822
+ query = _state["search_entry"].get_text().strip()
823
+
824
+ if len(query) < 3:
825
+ # Short query or empty: switch back to browse mode
826
+ if _state["search_mode"]:
827
+ _state["search_mode"] = False
828
+ _populate_list_box(_state["all_sessions"])
829
+ else:
830
+ # Just filter existing rows
831
+ if _state["list_box"] is not None:
832
+ _state["list_box"].invalidate_filter()
833
+ return
834
+
835
+ # Debounce: schedule grep search after 300ms
836
+ _state["debounce_id"] = GLib.timeout_add(300, _execute_search, query)
837
+
838
+
839
+ def _show_search_status(message: str) -> None:
840
+ """Clear the ListBox and show a status message placeholder. GTK-thread only."""
841
+ if _state["list_box"] is None:
842
+ return
843
+
844
+ # Cancel any running batch insertion first
845
+ if _state["batch_id"] is not None:
846
+ GLib.source_remove(_state["batch_id"])
847
+ _state["batch_id"] = None
848
+
849
+ for child in _state["list_box"].get_children():
850
+ _state["list_box"].remove(child)
851
+
852
+ placeholder = Gtk.Label(label=message)
853
+ placeholder.get_style_context().add_class("session-meta")
854
+ placeholder.set_margin_top(16)
855
+ placeholder.set_margin_bottom(16)
856
+ row = Gtk.ListBoxRow()
857
+ row.set_activatable(False)
858
+ row.add(placeholder)
859
+ row.show_all()
860
+ _state["list_box"].add(row)
861
+ _state["list_box"].show_all()
862
+
863
+
864
+ def _display_search_results(results: list, query: str) -> bool:
865
+ """Populate the ListBox with search results in batches. GTK-thread only.
866
+
867
+ Called via GLib.idle_add from the background worker thread.
868
+ Returns False (one-shot).
869
+ """
870
+ if _state["list_box"] is None:
871
+ return False
872
+
873
+ # Clear the "Suche..." placeholder (and any stale rows)
874
+ for child in _state["list_box"].get_children():
875
+ _state["list_box"].remove(child)
876
+
877
+ # Update stats bar
878
+ if _state["stats_label"] is not None:
879
+ total_matches = sum(s.get("match_count", 0) for s in results)
880
+ _state["stats_label"].set_text(
881
+ f"{len(results)} Sessions gefunden | {total_matches} Treffer gesamt | Suche: \"{query}\""
882
+ )
883
+
884
+ if not results:
885
+ _state["list_box"].show_all()
886
+ return False
887
+
888
+ # Cancel any previous batch insertion
889
+ if _state["batch_id"] is not None:
890
+ GLib.source_remove(_state["batch_id"])
891
+ _state["batch_id"] = None
892
+
893
+ # Batch-insert via idle_add
894
+ now = time.time()
895
+ it = iter(results)
896
+
897
+ def _insert_batch() -> bool:
898
+ BATCH = 30
899
+ try:
900
+ for _ in range(BATCH):
901
+ session = next(it)
902
+ row = _build_search_row(session, query, now)
903
+ _state["list_box"].add(row)
904
+ _state["list_box"].show_all()
905
+ return True # more rows pending
906
+ except StopIteration:
907
+ _state["list_box"].show_all()
908
+ _state["batch_id"] = None
909
+ return False # done
910
+
911
+ _state["batch_id"] = GLib.idle_add(_insert_batch)
912
+ return False # one-shot
913
+
914
+
915
+ def _execute_search(query: str) -> bool:
916
+ """Kick off a non-blocking grep search. Returns False (one-shot GLib timer)."""
917
+ global _search_cancel
918
+
919
+ _state["debounce_id"] = None
920
+ _state["search_mode"] = True
921
+
922
+ # Signal any previous background search to abort early
923
+ _search_cancel.set()
924
+ cancel = threading.Event()
925
+ _search_cancel = cancel
926
+
927
+ # Immediate visual feedback — clears the list and shows "Suche..."
928
+ _show_search_status("Suche…")
929
+
930
+ def _worker() -> None:
931
+ if cancel.is_set():
932
+ return
933
+ results = _grep_sessions(query)
934
+ if cancel.is_set():
935
+ return
936
+ GLib.idle_add(_display_search_results, results, query)
937
+
938
+ thread = threading.Thread(target=_worker, daemon=True)
939
+ thread.start()
940
+
941
+ return False # one-shot timer
942
+
943
+
944
+ # ---------------------------------------------------------------------------
945
+ # Public API
946
+ # ---------------------------------------------------------------------------
947
+
948
+ def refresh_sessions() -> bool:
949
+ """Re-scan sessions and update the UI. Returns True to keep GLib timer alive."""
950
+ try:
951
+ sessions = _scan_all_sessions()
952
+ GLib.idle_add(_populate_list_box, sessions)
953
+ except Exception:
954
+ log.exception("refresh sessions")
955
+ return True # keep timer running
956
+
957
+
958
+ def build_sessions_tab() -> Gtk.ScrolledWindow:
959
+ """Build and return the Sessions tab widget (Gtk.ScrolledWindow)."""
960
+
961
+ # -----------------------------------------------------------------------
962
+ # Root: ScrolledWindow → main VBox
963
+ # -----------------------------------------------------------------------
964
+ scrolled = Gtk.ScrolledWindow()
965
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
966
+ scrolled.set_hexpand(True)
967
+ scrolled.set_vexpand(True)
968
+
969
+ main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
970
+ main_vbox.set_margin_top(8)
971
+ main_vbox.set_margin_bottom(8)
972
+ main_vbox.set_margin_start(8)
973
+ main_vbox.set_margin_end(8)
974
+ scrolled.add(main_vbox)
975
+
976
+ # -----------------------------------------------------------------------
977
+ # Toolbar: title + search + refresh button
978
+ # -----------------------------------------------------------------------
979
+ toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
980
+ toolbar.set_margin_bottom(6)
981
+ main_vbox.pack_start(toolbar, False, False, 0)
982
+
983
+ title_label = Gtk.Label(label="Sessions")
984
+ title_label.get_style_context().add_class("section-title")
985
+ title_attrs = Pango.AttrList()
986
+ title_attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
987
+ title_attrs.insert(Pango.attr_scale_new(1.1))
988
+ title_label.set_attributes(title_attrs)
989
+ title_label.set_halign(Gtk.Align.START)
990
+ toolbar.pack_start(title_label, False, False, 0)
991
+
992
+ # Spacer
993
+ spacer = Gtk.Box()
994
+ spacer.set_hexpand(True)
995
+ toolbar.pack_start(spacer, True, True, 0)
996
+
997
+ # Search entry
998
+ _state["search_entry"] = Gtk.SearchEntry()
999
+ _state["search_entry"].set_placeholder_text("Projekt oder Inhalt suchen…")
1000
+ _state["search_entry"].set_size_request(220, -1)
1001
+ toolbar.pack_start(_state["search_entry"], False, False, 0)
1002
+
1003
+ # Refresh button
1004
+ refresh_btn = Gtk.Button()
1005
+ refresh_icon = Gtk.Image.new_from_icon_name("view-refresh-symbolic", Gtk.IconSize.BUTTON)
1006
+ refresh_btn.add(refresh_icon)
1007
+ refresh_btn.set_tooltip_text("Sessions neu einlesen")
1008
+ refresh_btn.connect("clicked", lambda _b: refresh_sessions())
1009
+ toolbar.pack_start(refresh_btn, False, False, 0)
1010
+
1011
+ # -----------------------------------------------------------------------
1012
+ # Stats pill (rounded bar instead of frame)
1013
+ # -----------------------------------------------------------------------
1014
+ _state["stats_label"] = Gtk.Label(label="Lade Sessions…")
1015
+ _state["stats_label"].get_style_context().add_class("session-stats")
1016
+ _state["stats_label"].set_margin_top(2)
1017
+ _state["stats_label"].set_margin_bottom(8)
1018
+ _state["stats_label"].set_halign(Gtk.Align.START)
1019
+ main_vbox.pack_start(_state["stats_label"], False, False, 0)
1020
+
1021
+ # -----------------------------------------------------------------------
1022
+ # ListBox for sessions
1023
+ # -----------------------------------------------------------------------
1024
+ _state["list_box"] = Gtk.ListBox()
1025
+ _state["list_box"].set_selection_mode(Gtk.SelectionMode.NONE)
1026
+ _state["list_box"].set_filter_func(_filter_func)
1027
+ _state["list_box"].get_style_context().add_class("view")
1028
+ main_vbox.pack_start(_state["list_box"], True, True, 0)
1029
+
1030
+ # Connect search to filter
1031
+ _state["search_entry"].connect("search-changed", _on_search_changed)
1032
+
1033
+ # -----------------------------------------------------------------------
1034
+ # Initial load (non-blocking via idle_add)
1035
+ # -----------------------------------------------------------------------
1036
+ idle_once(lambda: _populate_list_box(_scan_all_sessions()))
1037
+
1038
+ scrolled.show_all()
1039
+ return scrolled
shortcut_counter_tab.py ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Shortcut Counter Tab — GTK3 widget for ClaudeCodePanel.
3
+
4
+ Reads directly from ~/.local/share/shortcut-counter/shortcuts.db (read-only).
5
+ Refreshes every 30s via GLib timer registered in panel.py.
6
+
7
+ Provides:
8
+ build_shortcut_counter_tab() -> Gtk.ScrolledWindow
9
+ refresh_shortcut_counter(tab) -> bool
10
+ """
11
+
12
+ import sqlite3
13
+ import tomllib
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ import gi
18
+
19
+ gi.require_version("Gtk", "3.0")
20
+ from gi.repository import GLib, Gtk, Pango
21
+
22
+ from theme import get_palette, hex_to_pango_rgb
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # DB path (XDG)
26
+ # ---------------------------------------------------------------------------
27
+ _DB_PATH = Path.home() / ".local" / "share" / "shortcut-counter" / "shortcuts.db"
28
+
29
+ # Config path for "unused" recommendations
30
+ _CONFIG_PATH = Path.home() / "Projekte" / "ShortcutCounter" / "config.toml"
31
+
32
+ # Thresholds
33
+ _MASTERED = 50
34
+ _LEARNING_MIN = 1
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Module-level widget refs (stable outer structure)
38
+ # ---------------------------------------------------------------------------
39
+ _header_label: Gtk.Label | None = None
40
+ _progress_bar: Gtk.ProgressBar | None = None
41
+ _cards_container: Gtk.Box | None = None # replaces TreeView
42
+ _learn_box: Gtk.Box | None = None
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Data loading
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def _load_rows() -> list[dict]:
50
+ """Read all rows from shortcuts.db. Returns [] if DB missing."""
51
+ if not _DB_PATH.exists():
52
+ return []
53
+ try:
54
+ # Read raw values instead of sqlite3's TIMESTAMP converter. The
55
+ # shortcut counter stores ISO timestamps with a "T" separator, which
56
+ # sqlite3's default converter rejects.
57
+ conn = sqlite3.connect(f"file:{_DB_PATH}?mode=ro", uri=True)
58
+ conn.row_factory = sqlite3.Row
59
+ rows = conn.execute(
60
+ "SELECT combo, count, last_used, category FROM shortcuts ORDER BY count DESC"
61
+ ).fetchall()
62
+ conn.close()
63
+ return [dict(r) for r in rows]
64
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
65
+ return []
66
+
67
+
68
+ def _load_config() -> dict[str, list[dict]]:
69
+ """Load shortcut config with descriptions. Returns {} on any error."""
70
+ if not _CONFIG_PATH.exists():
71
+ return {}
72
+ try:
73
+ with open(_CONFIG_PATH, "rb") as f:
74
+ cfg = tomllib.load(f)
75
+ result = {}
76
+ for cat_name, cat_data in cfg.get("shortcuts", {}).items():
77
+ items = cat_data.get("items", [])
78
+ result[cat_name] = [{"combo": it["combo"], "desc": it.get("desc", "")} for it in items]
79
+ return result
80
+ except Exception:
81
+ return {}
82
+
83
+
84
+ # ---------------------------------------------------------------------------
85
+ # Status helpers
86
+ # ---------------------------------------------------------------------------
87
+
88
+ def _status_label(count: int) -> str:
89
+ if count >= _MASTERED:
90
+ return "Mastered"
91
+ elif count >= _LEARNING_MIN:
92
+ return "Learning"
93
+ else:
94
+ return "Not Used"
95
+
96
+
97
+ def _status_css_class(count: int) -> str:
98
+ if count >= _MASTERED:
99
+ return "sc-badge-mastered"
100
+ elif count >= _LEARNING_MIN:
101
+ return "sc-badge-learning"
102
+ else:
103
+ return "sc-badge-unused"
104
+
105
+
106
+ def _fmt_last_used(last_used) -> str:
107
+ if last_used is None:
108
+ return "—"
109
+ if isinstance(last_used, datetime):
110
+ return last_used.strftime("%d.%m %H:%M")
111
+ try:
112
+ dt = datetime.fromisoformat(str(last_used))
113
+ return dt.strftime("%d.%m %H:%M")
114
+ except (ValueError, TypeError):
115
+ return str(last_used)[:10]
116
+
117
+
118
+ def _find_category(combo: str, config: dict[str, list[dict]]) -> str:
119
+ """Try to find category for a combo via config dict."""
120
+ for cat_name, items in config.items():
121
+ if any(it["combo"].lower() == combo.lower() for it in items):
122
+ return cat_name
123
+ return "Sonstige"
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Card builders
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def _build_shortcut_row(r: dict, p: dict) -> Gtk.Box:
131
+ """Build one horizontal shortcut row inside a category card."""
132
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
133
+ row.set_margin_top(2)
134
+ row.set_margin_bottom(2)
135
+
136
+ # Combo label (monospace bold)
137
+ combo_lbl = Gtk.Label(label=r["combo"])
138
+ combo_lbl.set_halign(Gtk.Align.START)
139
+ combo_lbl.set_markup(
140
+ f'<tt><b>{GLib.markup_escape_text(r["combo"])}</b></tt>'
141
+ )
142
+ combo_lbl.set_width_chars(18)
143
+ combo_lbl.set_xalign(0.0)
144
+ row.pack_start(combo_lbl, False, False, 0)
145
+
146
+ # Description label (dim, between combo and count)
147
+ desc = r.get("desc", "")
148
+ desc_lbl = Gtk.Label()
149
+ desc_lbl.set_markup(
150
+ f'<span foreground="{p["dim"]}" size="small">{GLib.markup_escape_text(desc)}</span>'
151
+ )
152
+ desc_lbl.set_width_chars(26)
153
+ desc_lbl.set_xalign(0.0)
154
+ desc_lbl.set_halign(Gtk.Align.START)
155
+ row.pack_start(desc_lbl, False, False, 0)
156
+
157
+ # Count badge (dim text)
158
+ count_lbl = Gtk.Label(label=str(r["count"]))
159
+ count_lbl.set_markup(
160
+ f'<span foreground="{p["overlay"]}" size="small">{r["count"]}×</span>'
161
+ )
162
+ row.pack_start(count_lbl, False, False, 0)
163
+
164
+ # Status badge
165
+ status_lbl = Gtk.Label(label=_status_label(r["count"]))
166
+ status_lbl.get_style_context().add_class(_status_css_class(r["count"]))
167
+ row.pack_start(status_lbl, False, False, 0)
168
+
169
+ # Last-used (right-aligned, dim)
170
+ last_lbl = Gtk.Label()
171
+ last_lbl.set_markup(
172
+ f'<span foreground="{p["dim"]}" size="small">{_fmt_last_used(r.get("last_used"))}</span>'
173
+ )
174
+ last_lbl.set_halign(Gtk.Align.END)
175
+ row.pack_end(last_lbl, False, False, 0)
176
+
177
+ return row
178
+
179
+
180
+ def _build_category_card(cat_name: str, cat_rows: list[dict], p: dict) -> Gtk.Box:
181
+ """Build a full category card widget."""
182
+ card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
183
+ card.get_style_context().add_class("sc-card")
184
+
185
+ # Category header: bold name + progress "X/Y"
186
+ mastered = sum(1 for r in cat_rows if r["count"] >= _MASTERED)
187
+ used = sum(1 for r in cat_rows if r["count"] >= _LEARNING_MIN)
188
+ total = len(cat_rows)
189
+
190
+ header = Gtk.Label()
191
+ header.set_markup(
192
+ f'<b>{GLib.markup_escape_text(cat_name)}</b> '
193
+ f'<span foreground="{p["overlay"]}" size="small">'
194
+ f'{mastered} mastered · {used}/{total} used</span>'
195
+ )
196
+ header.set_halign(Gtk.Align.START)
197
+ header.set_margin_bottom(4)
198
+ card.pack_start(header, False, False, 0)
199
+
200
+ # Shortcut rows sorted by count desc
201
+ for r in sorted(cat_rows, key=lambda x: -x["count"]):
202
+ card.pack_start(_build_shortcut_row(r, p), False, False, 0)
203
+
204
+ return card
205
+
206
+
207
+ def _build_learn_card(combo: str, desc: str, cat_name: str, p: dict) -> Gtk.Box:
208
+ """Build a single Learn Next card."""
209
+ card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
210
+ card.get_style_context().add_class("sc-learn-card")
211
+
212
+ combo_lbl = Gtk.Label()
213
+ combo_lbl.set_markup(
214
+ f'<tt><b><span foreground="{p["text"]}">'
215
+ f'{GLib.markup_escape_text(combo)}</span></b></tt>'
216
+ )
217
+ combo_lbl.set_halign(Gtk.Align.START)
218
+ combo_lbl.get_style_context().add_class("section-title")
219
+ card.pack_start(combo_lbl, False, False, 0)
220
+
221
+ if desc:
222
+ desc_lbl = Gtk.Label()
223
+ desc_lbl.set_markup(
224
+ f'<span foreground="{p["text"]}" size="small">'
225
+ f'{GLib.markup_escape_text(desc)}</span>'
226
+ )
227
+ desc_lbl.set_halign(Gtk.Align.START)
228
+ card.pack_start(desc_lbl, False, False, 0)
229
+
230
+ cat_lbl = Gtk.Label()
231
+ cat_lbl.set_markup(
232
+ f'<span foreground="{p["dim"]}" size="small">'
233
+ f'{GLib.markup_escape_text(cat_name)}</span>'
234
+ )
235
+ cat_lbl.set_halign(Gtk.Align.START)
236
+ card.pack_start(cat_lbl, False, False, 0)
237
+
238
+ return card
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # UI update (always called on GTK main thread via idle_add)
243
+ # ---------------------------------------------------------------------------
244
+
245
+ def _do_refresh() -> bool:
246
+ """Actual UI update — must run on GTK thread. Returns False (one-shot)."""
247
+ rows = _load_rows()
248
+ config = _load_config()
249
+ p = get_palette()
250
+
251
+ # Flat list of all configured items for counting and lookup
252
+ all_config_items = [it for items in config.values() for it in items]
253
+ total_config = len(all_config_items)
254
+ used_count = sum(1 for r in rows if r["count"] >= _LEARNING_MIN)
255
+ mastered_count = sum(1 for r in rows if r["count"] >= _MASTERED)
256
+
257
+ # --- Header ---
258
+ if _header_label is not None:
259
+ if total_config > 0:
260
+ _header_label.set_markup(
261
+ f'<b>Shortcut Counter</b> '
262
+ f'<span foreground="{p["green"]}">{mastered_count} Mastered</span> '
263
+ f'<span foreground="{p["yellow"]}">{used_count} Learning</span> '
264
+ f'<span foreground="{p["overlay"]}">/ {total_config} konfiguriert</span>'
265
+ )
266
+ else:
267
+ _header_label.set_markup(
268
+ f'<b>Shortcut Counter</b> '
269
+ f'<span foreground="{p["overlay"]}">{len(rows)} gespeichert</span>'
270
+ )
271
+
272
+ # --- Progress bar ---
273
+ if _progress_bar is not None:
274
+ if total_config > 0:
275
+ frac = min(used_count / total_config, 1.0)
276
+ _progress_bar.set_fraction(frac)
277
+ _progress_bar.set_text(
278
+ f"{used_count} / {total_config} mindestens 1x benutzt ({frac*100:.0f}%)"
279
+ )
280
+ else:
281
+ _progress_bar.set_fraction(0.0)
282
+ _progress_bar.set_text("Keine Config-Datei gefunden")
283
+
284
+ # --- Category cards ---
285
+ if _cards_container is not None:
286
+ for child in _cards_container.get_children():
287
+ _cards_container.remove(child)
288
+
289
+ # Build desc lookup: combo -> desc from config (case-insensitive)
290
+ desc_lookup: dict[str, str] = {
291
+ it["combo"].lower(): it["desc"] for it in all_config_items
292
+ }
293
+
294
+ # Group DB rows by category, enriching with desc from config
295
+ categories: dict[str, list[dict]] = {}
296
+ for r in rows:
297
+ cat = r.get("category") or "Sonstige"
298
+ enriched = dict(r)
299
+ enriched["desc"] = desc_lookup.get(r["combo"].lower(), "")
300
+ categories.setdefault(cat, []).append(enriched)
301
+
302
+ # Add config items never used (not in DB)
303
+ db_combos = {r["combo"].lower() for r in rows}
304
+ for cat_name, items in config.items():
305
+ for it in items:
306
+ if it["combo"].lower() not in db_combos:
307
+ categories.setdefault(cat_name, []).append({
308
+ "combo": it["combo"],
309
+ "desc": it["desc"],
310
+ "count": 0,
311
+ "last_used": None,
312
+ "category": cat_name,
313
+ })
314
+
315
+ mastered_items: list[dict] = [] # collect individually mastered shortcuts
316
+ for cat_name, cat_rows in sorted(categories.items()):
317
+ learning = [r for r in cat_rows if r["count"] < _MASTERED]
318
+ mastered = [r for r in cat_rows if r["count"] >= _MASTERED]
319
+ # Tag mastered items with their category for the expander
320
+ for m in mastered:
321
+ m["_cat"] = cat_name
322
+ mastered_items.append(m)
323
+ # Only build a card if there are non-mastered items
324
+ if learning:
325
+ card = _build_category_card(cat_name, learning, p)
326
+ _cards_container.pack_start(card, False, False, 0)
327
+
328
+ if mastered_items:
329
+ expander = Gtk.Expander()
330
+ expander.set_label(f"Mastered ({len(mastered_items)} Shortcuts)")
331
+ expander.set_expanded(False)
332
+ exp_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
333
+ # Group mastered items by category inside the expander
334
+ from itertools import groupby
335
+ for cat, items in groupby(
336
+ sorted(mastered_items, key=lambda r: r.get("_cat", "")),
337
+ key=lambda r: r.get("_cat", ""),
338
+ ):
339
+ cat_card = _build_category_card(cat, list(items), p)
340
+ exp_box.pack_start(cat_card, False, False, 0)
341
+ expander.add(exp_box)
342
+ _cards_container.pack_start(expander, False, False, 0)
343
+
344
+ _cards_container.show_all()
345
+
346
+ # --- Learn Next section ---
347
+ if _learn_box is not None:
348
+ for child in _learn_box.get_children():
349
+ _learn_box.remove(child)
350
+
351
+ lbl = Gtk.Label()
352
+ lbl.set_markup(
353
+ f'<b>Learn Next</b> '
354
+ f'<span foreground="{p["overlay"]}">Top 3 ungenutzte Shortcuts</span>'
355
+ )
356
+ lbl.set_halign(Gtk.Align.START)
357
+ lbl.set_margin_bottom(4)
358
+ _learn_box.pack_start(lbl, False, False, 0)
359
+
360
+ db_combos = {r["combo"].lower() for r in rows}
361
+ unused = [
362
+ it for it in all_config_items
363
+ if it["combo"].lower() not in db_combos
364
+ or next((r["count"] for r in rows if r["combo"].lower() == it["combo"].lower()), 0) == 0
365
+ ]
366
+
367
+ if unused:
368
+ for it in unused[:3]:
369
+ cat = _find_category(it["combo"], config)
370
+ card = _build_learn_card(it["combo"], it["desc"], cat, p)
371
+ _learn_box.pack_start(card, False, False, 0)
372
+ else:
373
+ done = Gtk.Label()
374
+ done.set_markup(
375
+ f'<span foreground="{p["green"]}">'
376
+ f'Alle konfigurierten Shortcuts mindestens 1x benutzt!</span>'
377
+ )
378
+ done.set_halign(Gtk.Align.START)
379
+ _learn_box.pack_start(done, False, False, 0)
380
+
381
+ _learn_box.show_all()
382
+
383
+ return False # one-shot
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Public API
388
+ # ---------------------------------------------------------------------------
389
+
390
+ def refresh_shortcut_counter(_tab=None) -> bool:
391
+ """Called by GLib timer from panel.py. Schedules UI update via idle_add."""
392
+ GLib.idle_add(_do_refresh)
393
+ return True # keep timer alive
394
+
395
+
396
+ def build_shortcut_counter_tab() -> Gtk.ScrolledWindow:
397
+ """Build and return the Shortcut Counter tab widget."""
398
+ global _header_label, _progress_bar, _cards_container, _learn_box
399
+
400
+ scrolled = Gtk.ScrolledWindow()
401
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
402
+ scrolled.set_hexpand(True)
403
+ scrolled.set_vexpand(True)
404
+
405
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
406
+ vbox.set_margin_top(10)
407
+ vbox.set_margin_bottom(10)
408
+ vbox.set_margin_start(10)
409
+ vbox.set_margin_end(10)
410
+ scrolled.add(vbox)
411
+
412
+ p = get_palette()
413
+
414
+ # --- Header ---
415
+ _header_label = Gtk.Label()
416
+ _header_label.set_markup(
417
+ f'<b>Shortcut Counter</b> <span foreground="{p["overlay"]}">Lade...</span>'
418
+ )
419
+ _header_label.set_halign(Gtk.Align.START)
420
+ _header_label.set_margin_bottom(6)
421
+ header_attrs = Pango.AttrList()
422
+ header_attrs.insert(Pango.attr_scale_new(1.1))
423
+ _header_label.set_attributes(header_attrs)
424
+ vbox.pack_start(_header_label, False, False, 0)
425
+
426
+ # --- Progress bar ---
427
+ _progress_bar = Gtk.ProgressBar()
428
+ _progress_bar.set_show_text(True)
429
+ _progress_bar.set_fraction(0.0)
430
+ _progress_bar.set_text("Lade...")
431
+ _progress_bar.set_margin_bottom(10)
432
+ vbox.pack_start(_progress_bar, False, False, 0)
433
+
434
+ # --- Category cards container ---
435
+ _cards_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
436
+ _cards_container.set_margin_bottom(8)
437
+ vbox.pack_start(_cards_container, False, False, 0)
438
+
439
+ # --- Learn Next section ---
440
+ sep = Gtk.Separator()
441
+ sep.set_margin_top(4)
442
+ sep.set_margin_bottom(8)
443
+ vbox.pack_start(sep, False, False, 0)
444
+
445
+ _learn_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
446
+ vbox.pack_start(_learn_box, False, False, 0)
447
+
448
+ # Initial load
449
+ GLib.idle_add(_do_refresh)
450
+
451
+ scrolled.show_all()
452
+ return scrolled
swarm_tab.py ADDED
@@ -0,0 +1,974 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Swarm Tab — Native GTK3 view for Agent Teams.
3
+
4
+ Reads directly from ~/.claude/teams/ and ~/.claude/tasks/ —
5
+ no WebKit, no Flask server needed.
6
+
7
+ Provides build_swarm_tab() and refresh_swarm() with the same API
8
+ as the old WebKit-based version.
9
+ """
10
+
11
+ import json
12
+ import threading
13
+ import time
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ import gi
18
+
19
+ gi.require_version("Gtk", "3.0")
20
+ gi.require_version("WebKit2", "4.1")
21
+ from gi.repository import Gtk, GLib, Gio, Pango, WebKit2
22
+
23
+ from theme import get_palette
24
+ from monitor import (
25
+ get_anthropic_session_cost,
26
+ get_provider_costs,
27
+ get_active_sessions,
28
+ get_sidecar_status,
29
+ )
30
+ from swarm_visual import generate_comm_graph
31
+
32
+ SWARM_HTML = Path.home() / ".agent" / "diagrams" / "agent-swarm-live.html"
33
+
34
+ TEAMS_DIR = Path.home() / ".claude" / "teams"
35
+ TASKS_DIR = Path.home() / ".claude" / "tasks"
36
+ EVENTS_DIR = Path.home() / ".claude" / "events"
37
+
38
+ # Events file offset tracking (like event_tab.py)
39
+ _events_file_offset: int = 0
40
+ _events_current_day: str = ""
41
+
42
+ # Gio.FileMonitor ref (prevent GC)
43
+ _event_file_monitor: Gio.FileMonitor | None = None
44
+
45
+ # Module-level refs for refresh
46
+ _webview_ready: bool = False
47
+ _team_flow: Gtk.FlowBox | None = None
48
+ _task_list: Gtk.ListBox | None = None
49
+ _msg_list: Gtk.ListBox | None = None
50
+ _team_selector: Gtk.ComboBoxText | None = None
51
+ _status_label: Gtk.Label | None = None
52
+ _selected_team: str | None = None
53
+ _refreshing: bool = False
54
+ _stack: Gtk.Stack | None = None
55
+ _webview: WebKit2.WebView | None = None
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Data layer
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def _load_teams() -> list[dict]:
63
+ """Load all team configs from ~/.claude/teams/*/config.json."""
64
+ teams = []
65
+ if not TEAMS_DIR.exists():
66
+ return teams
67
+ for team_dir in sorted(TEAMS_DIR.iterdir()):
68
+ if not team_dir.is_dir():
69
+ continue
70
+ config = team_dir / "config.json"
71
+ if not config.exists():
72
+ continue
73
+ try:
74
+ data = json.loads(config.read_text(encoding="utf-8"))
75
+ name = data.get("name", team_dir.name)
76
+ members = data.get("members", [])
77
+ # Count tasks
78
+ task_dir = TASKS_DIR / team_dir.name
79
+ task_count = 0
80
+ active_tasks = 0
81
+ if task_dir.exists():
82
+ for tf in task_dir.glob("*.json"):
83
+ try:
84
+ td = json.loads(tf.read_text(encoding="utf-8"))
85
+ if td.get("metadata", {}).get("_internal"):
86
+ continue
87
+ task_count += 1
88
+ if td.get("status") == "in_progress":
89
+ mtime = tf.stat().st_mtime
90
+ if (time.time() - mtime) < 7200: # 2h
91
+ active_tasks += 1
92
+ except (json.JSONDecodeError, OSError):
93
+ pass
94
+ teams.append({
95
+ "name": name,
96
+ "dir_name": team_dir.name,
97
+ "members": len(members),
98
+ "member_names": [m.get("name", "?") for m in members],
99
+ "tasks": task_count,
100
+ "active_tasks": active_tasks,
101
+ "active": active_tasks > 0,
102
+ "created": data.get("createdAt", 0),
103
+ })
104
+ except (json.JSONDecodeError, OSError):
105
+ continue
106
+ return teams
107
+
108
+
109
+ def _load_tasks(team_name: str) -> list[dict]:
110
+ """Load tasks for a specific team."""
111
+ tasks = []
112
+ task_dir = TASKS_DIR / team_name
113
+ if not task_dir.exists():
114
+ return tasks
115
+ for tf in sorted(task_dir.glob("*.json"), key=lambda f: f.stem.zfill(10)):
116
+ try:
117
+ data = json.loads(tf.read_text(encoding="utf-8"))
118
+ # Skip internal task-assignment tasks
119
+ if data.get("metadata", {}).get("_internal"):
120
+ continue
121
+ tasks.append({
122
+ "id": data.get("id", tf.stem),
123
+ "subject": data.get("subject", "(kein Titel)"),
124
+ "description": data.get("description", "")[:120],
125
+ "status": data.get("status", "pending"),
126
+ "owner": data.get("owner", "(unassigned)"),
127
+ })
128
+ except (json.JSONDecodeError, OSError):
129
+ continue
130
+ # Sort: in_progress first, then pending, then completed
131
+ order = {"in_progress": 0, "pending": 1, "completed": 2}
132
+ tasks.sort(key=lambda t: order.get(t["status"], 9))
133
+ return tasks
134
+
135
+
136
+ def _collect_comm_data(team_name: str) -> dict:
137
+ """Collect per-member status and new messages for live updates."""
138
+ team_dir = TEAMS_DIR / team_name
139
+ config_path = team_dir / "config.json"
140
+ if not config_path.exists():
141
+ return {"members": [], "tasks": []}
142
+ config = json.loads(config_path.read_text(encoding="utf-8"))
143
+ members = []
144
+ for m in config.get("members", []):
145
+ name = m.get("name", "?")
146
+ inbox = team_dir / "inboxes" / f"{name}.json"
147
+ msg_count = 0
148
+ last_msg_time = ""
149
+ if inbox.exists():
150
+ try:
151
+ msgs = json.loads(inbox.read_text(encoding="utf-8"))
152
+ if isinstance(msgs, list):
153
+ # Filter internal messages
154
+ real = [x for x in msgs if not any(
155
+ k in x.get("text", "") for k in
156
+ ['"type":"task_assignment"', '"type":"shutdown_request"',
157
+ '"type":"idle_notification"'])]
158
+ msg_count = len(real)
159
+ if real:
160
+ last_msg_time = real[-1].get("timestamp", "")
161
+ except (json.JSONDecodeError, OSError):
162
+ pass
163
+ is_recent = False
164
+ if last_msg_time:
165
+ try:
166
+ ts = datetime.fromisoformat(last_msg_time.replace("Z", "+00:00"))
167
+ is_recent = (datetime.now(ts.tzinfo) - ts).total_seconds() < 120
168
+ except (ValueError, AttributeError):
169
+ pass
170
+ members.append({
171
+ "name": name,
172
+ "status": "working" if is_recent else "idle",
173
+ "msg_count": msg_count,
174
+ "last_msg": last_msg_time,
175
+ })
176
+
177
+ tasks = []
178
+ for t in _load_tasks(team_name):
179
+ tasks.append({
180
+ "id": t["id"],
181
+ "subject": t["subject"],
182
+ "status": t["status"],
183
+ "owner": t.get("owner", ""),
184
+ })
185
+
186
+ # Collect new messages for the log
187
+ all_msgs = []
188
+ inbox_dir = team_dir / "inboxes"
189
+ if inbox_dir.exists():
190
+ for mf in inbox_dir.glob("*.json"):
191
+ try:
192
+ data = json.loads(mf.read_text(encoding="utf-8"))
193
+ if not isinstance(data, list):
194
+ continue
195
+ recipient = mf.stem
196
+ for msg in data:
197
+ text = msg.get("text", "")
198
+ if any(k in text for k in ['"type":"task_assignment"',
199
+ '"type":"shutdown_request"',
200
+ '"type":"idle_notification"']):
201
+ continue
202
+ ts_str = msg.get("timestamp", "")
203
+ try:
204
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
205
+ time_fmt = ts.strftime("%H:%M")
206
+ except (ValueError, AttributeError):
207
+ time_fmt = "??:??"
208
+ all_msgs.append({
209
+ "time": time_fmt,
210
+ "sender": msg.get("from", "?"),
211
+ "recipient": recipient,
212
+ "text": text[:90].replace("\n", " "),
213
+ "timestamp": ts_str,
214
+ })
215
+ except (json.JSONDecodeError, OSError):
216
+ continue
217
+ all_msgs.sort(key=lambda m: m.get("timestamp", ""), reverse=True)
218
+
219
+ return {"members": members, "tasks": tasks, "new_messages": all_msgs[:5],
220
+ "events": []}
221
+
222
+
223
+ def _load_messages(team_name: str, limit: int = 10) -> list[dict]:
224
+ """Load recent messages from team inboxes."""
225
+ messages = []
226
+ inbox_dir = TEAMS_DIR / team_name / "inboxes"
227
+ if not inbox_dir.exists():
228
+ return messages
229
+ for mf in inbox_dir.glob("*.json"):
230
+ try:
231
+ data = json.loads(mf.read_text(encoding="utf-8"))
232
+ if not isinstance(data, list):
233
+ continue
234
+ recipient = mf.stem
235
+ for msg in data:
236
+ # Skip internal task assignments and shutdown requests
237
+ text = msg.get("text", "")
238
+ if '"type":"task_assignment"' in text or '"type":"shutdown_request"' in text:
239
+ continue
240
+ ts_str = msg.get("timestamp", "")
241
+ try:
242
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
243
+ time_fmt = ts.strftime("%H:%M")
244
+ sort_key = ts.timestamp()
245
+ except (ValueError, AttributeError):
246
+ time_fmt = "??:??"
247
+ sort_key = 0
248
+ sender = msg.get("from", "?")
249
+ # Truncate text for preview
250
+ preview = text[:100].replace("\n", " ")
251
+ if len(text) > 100:
252
+ preview += "..."
253
+ messages.append({
254
+ "time": time_fmt,
255
+ "sort_key": sort_key,
256
+ "sender": sender,
257
+ "recipient": recipient,
258
+ "preview": preview,
259
+ })
260
+ except (json.JSONDecodeError, OSError):
261
+ continue
262
+ messages.sort(key=lambda m: m["sort_key"], reverse=True)
263
+ return messages[:limit]
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Event feed — reads hook events from ~/.claude/events/YYYY-MM-DD.jsonl
268
+ # ---------------------------------------------------------------------------
269
+
270
+ _INTERESTING_EVENTS = {
271
+ "PostToolUse", "PreToolUse", "UserPromptSubmit",
272
+ "SubagentStart", "SubagentStop", "SubagentComplete",
273
+ }
274
+
275
+
276
+ def _read_recent_events(limit: int = 20) -> list[dict]:
277
+ """Read recent interesting events from today's event log.
278
+
279
+ Uses file offset tracking to only read new data (like event_tab.py).
280
+ """
281
+ global _events_file_offset, _events_current_day
282
+
283
+ today = datetime.now().strftime("%Y-%m-%d")
284
+ event_file = EVENTS_DIR / f"{today}.jsonl"
285
+
286
+ if not event_file.exists():
287
+ return []
288
+
289
+ # Day rollover — seek near end instead of reading from byte 0
290
+ if today != _events_current_day:
291
+ file_size = event_file.stat().st_size
292
+ _events_file_offset = max(0, file_size - 50_000)
293
+ _events_current_day = today
294
+
295
+ new_lines = []
296
+ try:
297
+ with open(event_file, "r", encoding="utf-8") as f:
298
+ f.seek(_events_file_offset)
299
+ new_lines = f.readlines()
300
+ _events_file_offset = f.tell()
301
+ except OSError:
302
+ pass
303
+
304
+ # Cap at last 100 lines to avoid processing stale history on day rollover
305
+ if len(new_lines) > 100:
306
+ new_lines = new_lines[-100:]
307
+
308
+ events = []
309
+ for line in new_lines:
310
+ line = line.strip()
311
+ if not line:
312
+ continue
313
+ try:
314
+ ev = json.loads(line)
315
+ et = ev.get("event_type") or ev.get("hook_event_name", "")
316
+ if et in _INTERESTING_EVENTS:
317
+ events.append({
318
+ "type": et,
319
+ "tool": ev.get("tool_name", ""),
320
+ "time": ev.get("logged_iso", ""),
321
+ "session": ev.get("session_id", "")[:8],
322
+ "agent": ev.get("tool_input", {}).get("agentName", ""),
323
+ })
324
+ except json.JSONDecodeError:
325
+ pass
326
+
327
+ # Return only the last `limit` events
328
+ return events[-limit:]
329
+
330
+
331
+ def _update_events_ui(events: list[dict]) -> bool:
332
+ """Push events to the WebKit view on the GTK main thread. idle_add callback."""
333
+ if not (_stack and _stack.get_visible_child_name() == "visual"
334
+ and _webview and _webview_ready):
335
+ return False
336
+ js = f"if(typeof updateEvents==='function')updateEvents({json.dumps(events, ensure_ascii=True)})"
337
+ _webview.run_javascript(js, None, None, None)
338
+ return False # one-shot: do not repeat
339
+
340
+
341
+ def _read_events_async() -> None:
342
+ """Read events in background thread, update UI via idle_add."""
343
+ def _worker():
344
+ events = _read_recent_events(20)
345
+ if events:
346
+ GLib.idle_add(_update_events_ui, events)
347
+
348
+ thread = threading.Thread(target=_worker, daemon=True)
349
+ thread.start()
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Live data collection for WebKit visual view
354
+ # ---------------------------------------------------------------------------
355
+
356
+ def _collect_live_data() -> dict:
357
+ """Collect live data from monitor.py + local team/task files.
358
+
359
+ Returns a dict matching the JSON structure that updateSwarm() expects.
360
+ """
361
+ cost_data = get_anthropic_session_cost()
362
+ provider_costs = get_provider_costs()
363
+ active_sessions = get_active_sessions()
364
+ sidecar = get_sidecar_status()
365
+ teams = _load_teams()
366
+
367
+ # Cost bars (percentage relative to reasonable daily max)
368
+ OPUS_MAX = 5.0
369
+ SONNET_MAX = 10.0
370
+ HAIKU_MAX = 2.0
371
+ GEMINI_MAX = 1.0
372
+
373
+ models = cost_data.get("models", {})
374
+ opus_cost = models.get("opus", {}).get("cost_usd", 0.0)
375
+ sonnet_cost = models.get("sonnet", {}).get("cost_usd", 0.0)
376
+ haiku_cost = models.get("haiku", {}).get("cost_usd", 0.0)
377
+ gemini_cost = provider_costs.get("gemini", 0.0)
378
+
379
+ # Node statuses
380
+ active_task_count = sum(t["active_tasks"] for t in teams)
381
+ sidecar_firing = any(
382
+ d.get("active") for d in sidecar.get("detectors", {}).values()
383
+ )
384
+
385
+ # Build tasks list from all teams
386
+ all_tasks = []
387
+ for team in teams:
388
+ for task in _load_tasks(team["dir_name"]):
389
+ task["team"] = team["dir_name"]
390
+ all_tasks.append(task)
391
+
392
+ return {
393
+ "agents": {
394
+ "opus": {
395
+ "status": "working" if any(
396
+ s.get("model", "").startswith("claude-opus") for s in active_sessions
397
+ ) else "idle",
398
+ "cost_usd": opus_cost,
399
+ "cost_pct": min(100, round(opus_cost / OPUS_MAX * 100, 1)),
400
+ },
401
+ "sonnet": {
402
+ "status": "working" if any(
403
+ s.get("model", "").startswith("claude-sonnet") for s in active_sessions
404
+ ) else "idle",
405
+ "cost_usd": sonnet_cost,
406
+ "cost_pct": min(100, round(sonnet_cost / SONNET_MAX * 100, 1)),
407
+ },
408
+ "haiku": {
409
+ "status": "working" if any(
410
+ s.get("model", "").startswith("claude-haiku") for s in active_sessions
411
+ ) else "idle",
412
+ "cost_usd": haiku_cost,
413
+ "cost_pct": min(100, round(haiku_cost / HAIKU_MAX * 100, 1)),
414
+ },
415
+ },
416
+ "gemini": {
417
+ "status": "working" if gemini_cost > 0 else "idle",
418
+ "cost_usd": gemini_cost,
419
+ "cost_pct": min(100, round(gemini_cost / GEMINI_MAX * 100, 1)),
420
+ },
421
+ "sidecar": {
422
+ "status": "working" if sidecar_firing else "idle",
423
+ "overall_severity": sidecar.get("overall_severity", "none"),
424
+ "active_detectors": [
425
+ name for name, d in sidecar.get("detectors", {}).items()
426
+ if d.get("active")
427
+ ],
428
+ "hook_events": sidecar.get("hook_events", 0),
429
+ },
430
+ "teams": [
431
+ {
432
+ "name": t["name"],
433
+ "dir_name": t["dir_name"],
434
+ "members": t["member_names"],
435
+ "status": "working" if t["active_tasks"] > 0 else "idle",
436
+ "tasks_total": t["tasks"],
437
+ "tasks_active": t["active_tasks"],
438
+ }
439
+ for t in teams
440
+ ],
441
+ "tasks": [
442
+ {
443
+ "id": task["id"],
444
+ "subject": task["subject"],
445
+ "status": task["status"],
446
+ "owner": task.get("owner", ""),
447
+ "team": task.get("team", ""),
448
+ }
449
+ for task in all_tasks
450
+ ],
451
+ "total_cost_usd": cost_data.get("cost_usd", 0.0),
452
+ "active_agent_count": len(active_sessions),
453
+ "events": [],
454
+ }
455
+
456
+
457
+ # ---------------------------------------------------------------------------
458
+ # UI builders
459
+ # ---------------------------------------------------------------------------
460
+
461
+ def _build_team_card(team: dict) -> Gtk.Box:
462
+ """Build a single team card widget."""
463
+ card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
464
+ card.get_style_context().add_class("sc-card")
465
+ card.set_size_request(180, -1)
466
+
467
+ # Team name
468
+ name_label = Gtk.Label(label=team["name"])
469
+ name_label.set_halign(Gtk.Align.START)
470
+ name_label.get_style_context().add_class("session-project")
471
+ card.pack_start(name_label, False, False, 0)
472
+
473
+ # Stats line
474
+ stats = f"{team['members']} Agents | {team['tasks']} Tasks"
475
+ stats_label = Gtk.Label(label=stats)
476
+ stats_label.set_halign(Gtk.Align.START)
477
+ stats_label.get_style_context().add_class("session-meta")
478
+ card.pack_start(stats_label, False, False, 0)
479
+
480
+ # Active indicator
481
+ if team["active"]:
482
+ dot = Gtk.Label(label=f"\u25cf Aktiv ({team['active_tasks']} laufen)")
483
+ dot.get_style_context().add_class("swarm-status-connected")
484
+ else:
485
+ dot = Gtk.Label(label="\u25cb Idle")
486
+ dot.get_style_context().add_class("swarm-status-disconnected")
487
+ dot.set_halign(Gtk.Align.START)
488
+ card.pack_start(dot, False, False, 0)
489
+
490
+ return card
491
+
492
+
493
+ def _build_task_row(task: dict) -> Gtk.ListBoxRow:
494
+ """Build a single task row."""
495
+ row = Gtk.ListBoxRow()
496
+ row.get_style_context().add_class("swarm-task-row")
497
+
498
+ hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
499
+ hbox.set_margin_top(4)
500
+ hbox.set_margin_bottom(4)
501
+ hbox.set_margin_start(8)
502
+ hbox.set_margin_end(8)
503
+ row.add(hbox)
504
+
505
+ # Status icon
506
+ status = task["status"]
507
+ if status == "in_progress":
508
+ icon_text = "\u25cf"
509
+ badge_class = "swarm-badge-progress"
510
+ elif status == "completed":
511
+ icon_text = "\u2713"
512
+ badge_class = "swarm-badge-completed"
513
+ else:
514
+ icon_text = "\u25cb"
515
+ badge_class = "swarm-badge-pending"
516
+
517
+ icon = Gtk.Label(label=icon_text)
518
+ icon.get_style_context().add_class(badge_class)
519
+ icon.set_size_request(20, -1)
520
+ hbox.pack_start(icon, False, False, 0)
521
+
522
+ # Task subject
523
+ subject = Gtk.Label(label=task["subject"])
524
+ subject.set_halign(Gtk.Align.START)
525
+ subject.set_ellipsize(Pango.EllipsizeMode.END)
526
+ subject.set_max_width_chars(50)
527
+ subject.set_hexpand(True)
528
+ subject.get_style_context().add_class("session-preview")
529
+ hbox.pack_start(subject, True, True, 0)
530
+
531
+ # Status badge text
532
+ status_text = status.upper().replace("_", " ")
533
+ badge = Gtk.Label(label=status_text)
534
+ badge.get_style_context().add_class(badge_class)
535
+ badge.set_halign(Gtk.Align.END)
536
+ hbox.pack_start(badge, False, False, 0)
537
+
538
+ row.show_all()
539
+ return row
540
+
541
+
542
+ def _build_message_row(msg: dict) -> Gtk.ListBoxRow:
543
+ """Build a single message row."""
544
+ row = Gtk.ListBoxRow()
545
+ row.get_style_context().add_class("swarm-message")
546
+
547
+ hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
548
+ hbox.set_margin_top(2)
549
+ hbox.set_margin_bottom(2)
550
+ hbox.set_margin_start(8)
551
+ hbox.set_margin_end(8)
552
+ row.add(hbox)
553
+
554
+ # Timestamp
555
+ time_label = Gtk.Label(label=msg["time"])
556
+ time_label.get_style_context().add_class("log-time")
557
+ time_label.set_size_request(40, -1)
558
+ hbox.pack_start(time_label, False, False, 0)
559
+
560
+ # Sender -> Recipient
561
+ route = Gtk.Label(label=f"{msg['sender']} \u2192 {msg['recipient']}")
562
+ route.get_style_context().add_class("session-meta")
563
+ route.set_size_request(160, -1)
564
+ route.set_halign(Gtk.Align.START)
565
+ route.set_ellipsize(Pango.EllipsizeMode.END)
566
+ hbox.pack_start(route, False, False, 0)
567
+
568
+ # Preview
569
+ preview = Gtk.Label(label=msg["preview"])
570
+ preview.set_halign(Gtk.Align.START)
571
+ preview.set_ellipsize(Pango.EllipsizeMode.END)
572
+ preview.set_hexpand(True)
573
+ preview.get_style_context().add_class("session-preview")
574
+ hbox.pack_start(preview, True, True, 0)
575
+
576
+ row.show_all()
577
+ return row
578
+
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # Refresh logic
582
+ # ---------------------------------------------------------------------------
583
+
584
+ def _refresh_content() -> None:
585
+ """Refresh all swarm content based on selected team."""
586
+ global _selected_team, _refreshing
587
+ if _refreshing:
588
+ return
589
+ _refreshing = True
590
+ try:
591
+ _refresh_content_inner()
592
+ finally:
593
+ _refreshing = False
594
+
595
+
596
+ def _refresh_content_inner() -> None:
597
+ """Actual refresh logic — guarded by _refreshing flag."""
598
+ global _selected_team
599
+
600
+ teams = _load_teams()
601
+
602
+ # Update status
603
+ if _status_label:
604
+ total = len(teams)
605
+ active = sum(1 for t in teams if t["active"])
606
+ _status_label.set_text(f"{total} Teams, {active} aktiv")
607
+
608
+ # Update team cards
609
+ if _team_flow:
610
+ for child in _team_flow.get_children():
611
+ _team_flow.remove(child)
612
+ for team in teams:
613
+ card = _build_team_card(team)
614
+ flow_child = Gtk.FlowBoxChild()
615
+ flow_child.add(card)
616
+ flow_child.show_all()
617
+ _team_flow.add(flow_child)
618
+ _team_flow.show_all()
619
+
620
+ # Update selector
621
+ if _team_selector:
622
+ current = _team_selector.get_active_text()
623
+ _team_selector.remove_all()
624
+ _team_selector.append_text("Alle Teams")
625
+ for team in teams:
626
+ _team_selector.append_text(team["dir_name"])
627
+ # Restore selection
628
+ if current:
629
+ model = _team_selector.get_model()
630
+ for i, row in enumerate(model):
631
+ if row[0] == current:
632
+ _team_selector.set_active(i)
633
+ break
634
+ else:
635
+ _team_selector.set_active(0)
636
+ else:
637
+ _team_selector.set_active(0)
638
+
639
+ active_text = _team_selector.get_active_text() if _team_selector else None
640
+ if active_text and active_text != "Alle Teams":
641
+ _selected_team = active_text
642
+ else:
643
+ _selected_team = None
644
+
645
+ # Update tasks
646
+ if _task_list:
647
+ for child in _task_list.get_children():
648
+ _task_list.remove(child)
649
+ if _selected_team:
650
+ tasks = _load_tasks(_selected_team)
651
+ else:
652
+ # All teams
653
+ tasks = []
654
+ for team in teams:
655
+ for t in _load_tasks(team["dir_name"]):
656
+ t["_team"] = team["dir_name"]
657
+ tasks.append(t)
658
+ order = {"in_progress": 0, "pending": 1, "completed": 2}
659
+ tasks.sort(key=lambda t: order.get(t["status"], 9))
660
+ for task in tasks:
661
+ _task_list.add(_build_task_row(task))
662
+ _task_list.show_all()
663
+
664
+ # Update messages
665
+ if _msg_list:
666
+ for child in _msg_list.get_children():
667
+ _msg_list.remove(child)
668
+ if _selected_team:
669
+ messages = _load_messages(_selected_team)
670
+ else:
671
+ messages = []
672
+ for team in teams:
673
+ messages.extend(_load_messages(team["dir_name"], limit=5))
674
+ messages.sort(key=lambda m: m["sort_key"], reverse=True)
675
+ messages = messages[:10]
676
+ for msg in messages:
677
+ _msg_list.add(_build_message_row(msg))
678
+ _msg_list.show_all()
679
+
680
+
681
+ # ---------------------------------------------------------------------------
682
+ # Public API
683
+ # ---------------------------------------------------------------------------
684
+
685
+ def _update_visual_for_team() -> None:
686
+ """Load comm graph HTML for the selected team, or fallback HTML for all."""
687
+ if not _stack or _stack.get_visible_child_name() != "visual" or not _webview:
688
+ return
689
+ active_text = _team_selector.get_active_text() if _team_selector else None
690
+ if active_text and active_text != "Alle Teams":
691
+ html = generate_comm_graph(active_text)
692
+ _webview.load_html(html, None)
693
+ elif SWARM_HTML.exists():
694
+ _webview.load_uri(SWARM_HTML.as_uri())
695
+
696
+
697
+ def _on_load_changed(webview, event):
698
+ """Track when WebKit page has finished loading."""
699
+ global _webview_ready
700
+ if event == WebKit2.LoadEvent.FINISHED:
701
+ _webview_ready = True
702
+
703
+
704
+ def _build_visual_view() -> Gtk.Widget:
705
+ """Build the WebKit visual view loading agent-swarm-live.html."""
706
+ global _webview
707
+
708
+ _webview = WebKit2.WebView()
709
+ settings = _webview.get_settings()
710
+ settings.set_enable_javascript(True)
711
+ settings.set_enable_developer_extras(False)
712
+ settings.set_hardware_acceleration_policy(
713
+ WebKit2.HardwareAccelerationPolicy.NEVER
714
+ )
715
+ _webview.connect("load-changed", _on_load_changed)
716
+
717
+ if SWARM_HTML.exists():
718
+ _webview.load_uri(SWARM_HTML.as_uri())
719
+ else:
720
+ p = get_palette()
721
+ _webview.load_html(
722
+ f'<html><body style="background:{p["bg"]};color:{p["text"]};font-family:system-ui;'
723
+ 'display:flex;align-items:center;justify-content:center;height:100vh">'
724
+ f'<div>agent-swarm-live.html nicht gefunden<br>'
725
+ f'<small style="color:{p["overlay"]}">{SWARM_HTML}</small></div>'
726
+ '</body></html>',
727
+ None,
728
+ )
729
+
730
+ return _webview
731
+
732
+
733
+ def build_swarm_tab() -> Gtk.Box:
734
+ """Build and return the Swarm tab widget."""
735
+ global _team_flow, _task_list, _msg_list, _team_selector, _status_label, _stack
736
+
737
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
738
+
739
+ # --- Toolbar ---
740
+ toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
741
+ toolbar.set_margin_start(8)
742
+ toolbar.set_margin_end(8)
743
+ toolbar.set_margin_top(6)
744
+ toolbar.set_margin_bottom(4)
745
+ toolbar.get_style_context().add_class("swarm-toolbar")
746
+
747
+ title = Gtk.Label(label="Swarm")
748
+ title.get_style_context().add_class("section-title")
749
+ title_attrs = Pango.AttrList()
750
+ title_attrs.insert(Pango.attr_weight_new(Pango.Weight.BOLD))
751
+ title_attrs.insert(Pango.attr_scale_new(1.1))
752
+ title.set_attributes(title_attrs)
753
+ toolbar.pack_start(title, False, False, 0)
754
+
755
+ # View toggle: Daten / Visual
756
+ view_toggle = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
757
+ view_toggle.get_style_context().add_class("linked")
758
+
759
+ btn_data = Gtk.ToggleButton(label="Daten")
760
+ btn_visual = Gtk.ToggleButton(label="Visual")
761
+ btn_data.set_active(True)
762
+ btn_data.get_style_context().add_class("swarm-btn")
763
+ btn_visual.get_style_context().add_class("swarm-btn")
764
+
765
+ def _on_toggle_data(btn):
766
+ if btn.get_active():
767
+ btn_visual.set_active(False)
768
+ if _stack:
769
+ _stack.set_visible_child_name("data")
770
+ elif not btn_visual.get_active():
771
+ btn.set_active(True)
772
+
773
+ def _on_toggle_visual(btn):
774
+ if btn.get_active():
775
+ btn_data.set_active(False)
776
+ if _stack:
777
+ _stack.set_visible_child_name("visual")
778
+ _update_visual_for_team()
779
+ elif not btn_data.get_active():
780
+ btn.set_active(True)
781
+
782
+ btn_data.connect("toggled", _on_toggle_data)
783
+ btn_visual.connect("toggled", _on_toggle_visual)
784
+ view_toggle.pack_start(btn_data, False, False, 0)
785
+ view_toggle.pack_start(btn_visual, False, False, 0)
786
+ toolbar.pack_start(view_toggle, False, False, 4)
787
+
788
+ # Team selector
789
+ _team_selector = Gtk.ComboBoxText()
790
+ _team_selector.append_text("Alle Teams")
791
+ _team_selector.set_active(0)
792
+ def _on_team_changed(_selector):
793
+ _refresh_content()
794
+ _update_visual_for_team()
795
+ _team_selector.connect("changed", _on_team_changed)
796
+ toolbar.pack_start(_team_selector, False, False, 4)
797
+
798
+ # Spacer
799
+ spacer = Gtk.Box()
800
+ spacer.set_hexpand(True)
801
+ toolbar.pack_start(spacer, True, True, 0)
802
+
803
+ _status_label = Gtk.Label(label="")
804
+ _status_label.get_style_context().add_class("session-stats")
805
+ toolbar.pack_end(_status_label, False, False, 4)
806
+
807
+ reload_btn = Gtk.Button(label="\u27f3 Refresh")
808
+ reload_btn.get_style_context().add_class("swarm-btn")
809
+ def _on_refresh(_):
810
+ if _stack and _stack.get_visible_child_name() == "visual" and _webview:
811
+ _webview.reload()
812
+ else:
813
+ _refresh_content()
814
+ reload_btn.connect("clicked", _on_refresh)
815
+ toolbar.pack_end(reload_btn, False, False, 0)
816
+
817
+ box.pack_start(toolbar, False, False, 0)
818
+ box.pack_start(Gtk.Separator(), False, False, 0)
819
+
820
+ # --- Stack: Data view + Visual view ---
821
+ _stack = Gtk.Stack()
822
+ _stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
823
+ _stack.set_transition_duration(200)
824
+
825
+ # Data view (native GTK)
826
+ scrolled = Gtk.ScrolledWindow()
827
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
828
+
829
+ content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
830
+ content.set_margin_top(8)
831
+ content.set_margin_bottom(8)
832
+ content.set_margin_start(8)
833
+ content.set_margin_end(8)
834
+
835
+ # --- Team overview (FlowBox) ---
836
+ team_label = Gtk.Label(label="Teams")
837
+ team_label.set_halign(Gtk.Align.START)
838
+ team_label.get_style_context().add_class("section-title")
839
+ content.pack_start(team_label, False, False, 0)
840
+
841
+ _team_flow = Gtk.FlowBox()
842
+ _team_flow.set_min_children_per_line(1)
843
+ _team_flow.set_max_children_per_line(5)
844
+ _team_flow.set_selection_mode(Gtk.SelectionMode.NONE)
845
+ _team_flow.set_homogeneous(True)
846
+ content.pack_start(_team_flow, False, False, 0)
847
+
848
+ # --- Tasks ---
849
+ task_label = Gtk.Label(label="Tasks")
850
+ task_label.set_halign(Gtk.Align.START)
851
+ task_label.set_margin_top(8)
852
+ task_label.get_style_context().add_class("section-title")
853
+ content.pack_start(task_label, False, False, 0)
854
+
855
+ _task_list = Gtk.ListBox()
856
+ _task_list.set_selection_mode(Gtk.SelectionMode.NONE)
857
+ content.pack_start(_task_list, False, False, 0)
858
+
859
+ # --- Messages ---
860
+ msg_label = Gtk.Label(label="Messages (letzte 10)")
861
+ msg_label.set_halign(Gtk.Align.START)
862
+ msg_label.set_margin_top(8)
863
+ msg_label.get_style_context().add_class("section-title")
864
+ content.pack_start(msg_label, False, False, 0)
865
+
866
+ _msg_list = Gtk.ListBox()
867
+ _msg_list.set_selection_mode(Gtk.SelectionMode.NONE)
868
+ content.pack_start(_msg_list, False, False, 0)
869
+
870
+ scrolled.add(content)
871
+ _stack.add_named(scrolled, "data")
872
+
873
+ # Visual view (WebKit)
874
+ _stack.add_named(_build_visual_view(), "visual")
875
+
876
+ box.pack_start(_stack, True, True, 0)
877
+
878
+ # Initial load + event file monitor
879
+ GLib.idle_add(_refresh_content)
880
+ GLib.idle_add(_setup_event_monitor)
881
+
882
+ return box
883
+
884
+
885
+ def _push_events_to_webview() -> None:
886
+ """Read new events and push them to the WebKit visual view via JS."""
887
+ if not (_stack and _stack.get_visible_child_name() == "visual"
888
+ and _webview and _webview_ready):
889
+ return
890
+ events = _read_recent_events(20)
891
+ if not events:
892
+ return
893
+ js = f"if(typeof updateEvents==='function')updateEvents({json.dumps(events, ensure_ascii=True)})"
894
+ _webview.run_javascript(js, None, None, None)
895
+
896
+
897
+ def _on_events_file_changed(_monitor, file, _other, event_type) -> None:
898
+ """Gio.FileMonitor callback — fires on every append to the JSONL file."""
899
+ if event_type not in (Gio.FileMonitorEvent.CHANGED, Gio.FileMonitorEvent.CHANGES_DONE_HINT):
900
+ return
901
+ # Schedule on GLib main loop (file monitor callback may be on any thread)
902
+ GLib.idle_add(_push_events_to_webview)
903
+
904
+
905
+ def _setup_event_monitor() -> None:
906
+ """Set up Gio.FileMonitor on today's event JSONL file.
907
+
908
+ Called once from build_swarm_tab(). Re-called on day rollover from refresh_swarm().
909
+ """
910
+ global _event_file_monitor, _events_current_day, _events_file_offset
911
+
912
+ today = datetime.now().strftime("%Y-%m-%d")
913
+ if _events_current_day == today and _event_file_monitor is not None:
914
+ return # already monitoring today's file
915
+
916
+ # Cancel previous monitor
917
+ if _event_file_monitor is not None:
918
+ _event_file_monitor.cancel()
919
+ _event_file_monitor = None
920
+
921
+ _events_current_day = today
922
+ _events_file_offset = 0
923
+
924
+ event_file = EVENTS_DIR / f"{today}.jsonl"
925
+ if not event_file.exists():
926
+ # File doesn't exist yet — check again on next timer tick
927
+ return
928
+
929
+ try:
930
+ gfile = Gio.File.new_for_path(str(event_file))
931
+ _event_file_monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
932
+ _event_file_monitor.connect("changed", _on_events_file_changed)
933
+ except GLib.Error:
934
+ pass
935
+
936
+
937
+ def refresh_swarm() -> bool:
938
+ """Called by GLib timer from panel.py every 30s.
939
+
940
+ Handles team/task data refresh + day rollover for event monitor.
941
+ Event updates are pushed instantly via Gio.FileMonitor (no polling).
942
+ """
943
+ # Skip if swarm tab is not visible (MiniMax M1 fix)
944
+ if _stack and not _stack.get_mapped():
945
+ return True
946
+
947
+ # Re-check event monitor (handles day rollover + late file creation)
948
+ _setup_event_monitor()
949
+
950
+ try:
951
+ _refresh_content()
952
+ # Async event read — offloaded to background thread
953
+ _read_events_async()
954
+ # Also update visual view if visible and ready
955
+ if (
956
+ _stack
957
+ and _stack.get_visible_child_name() == "visual"
958
+ and _webview
959
+ and _webview_ready
960
+ ):
961
+ active_text = _team_selector.get_active_text() if _team_selector else None
962
+ if active_text and active_text != "Alle Teams":
963
+ # Comm graph: inject updateGraph() with live data
964
+ data = _collect_comm_data(active_text)
965
+ js = f"if(typeof updateGraph==='function')updateGraph({json.dumps(data, ensure_ascii=True)})"
966
+ _webview.run_javascript(js, None, None, None)
967
+ else:
968
+ # Generic swarm view: inject updateSwarm()
969
+ data = _collect_live_data()
970
+ js = f"if(typeof updateSwarm==='function')updateSwarm({json.dumps(data, ensure_ascii=True)})"
971
+ _webview.run_javascript(js, None, None, None)
972
+ except Exception:
973
+ pass
974
+ return True # keep timer alive
swarm_visual.py ADDED
@@ -0,0 +1,708 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Swarm Visual — HTML generator for live agent communication graphs.
3
+
4
+ Generates a self-contained HTML page with hub-and-spoke layout showing
5
+ real team members, their connections via bezier curves, animated particles
6
+ with trail effects, and live-updating status.
7
+ """
8
+
9
+ import json
10
+ import math
11
+ from datetime import datetime, timezone
12
+ from html import escape as _esc
13
+ from pathlib import Path
14
+
15
+ TEAMS_DIR = Path.home() / ".claude" / "teams"
16
+ TASKS_DIR = Path.home() / ".claude" / "tasks"
17
+
18
+ # Color mapping for member.color -> CSS hex
19
+ COLOR_MAP = {
20
+ "pink": "#f5a0c0",
21
+ "green": "#98c379",
22
+ "cyan": "#56b6c2",
23
+ "blue": "#61afef",
24
+ "purple": "#c678dd",
25
+ "yellow": "#e5c07b",
26
+ "red": "#e06c75",
27
+ "orange": "#d19a66",
28
+ }
29
+
30
+ MODEL_BADGE = {
31
+ "claude-opus-4-6": ("opus", "#c678dd"),
32
+ "opus": ("opus", "#c678dd"),
33
+ "claude-sonnet-4-6": ("sonnet", "#61afef"),
34
+ "sonnet": ("sonnet", "#61afef"),
35
+ "claude-haiku-4-5": ("haiku", "#98c379"),
36
+ "haiku": ("haiku", "#98c379"),
37
+ }
38
+
39
+
40
+ def _is_recent(ts_str: str, seconds: int = 120) -> bool:
41
+ if not ts_str:
42
+ return False
43
+ try:
44
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
45
+ return (datetime.now(timezone.utc) - ts).total_seconds() < seconds
46
+ except (ValueError, AttributeError):
47
+ return False
48
+
49
+
50
+ def _load_inbox(team_dir: Path, member_name: str) -> list[dict]:
51
+ inbox = team_dir / "inboxes" / f"{member_name}.json"
52
+ if not inbox.exists():
53
+ return []
54
+ try:
55
+ data = json.loads(inbox.read_text(encoding="utf-8"))
56
+ if not isinstance(data, list):
57
+ return []
58
+ return [
59
+ m for m in data
60
+ if not any(k in m.get("text", "") for k in
61
+ ['"type":"task_assignment"', '"type":"shutdown_request"',
62
+ '"type":"idle_notification"'])
63
+ ]
64
+ except (json.JSONDecodeError, OSError):
65
+ return []
66
+
67
+
68
+ def _load_tasks(team_name: str) -> list[dict]:
69
+ tasks = []
70
+ task_dir = TASKS_DIR / team_name
71
+ if not task_dir.exists():
72
+ return tasks
73
+ for tf in sorted(task_dir.glob("*.json"), key=lambda f: f.stem.zfill(10)):
74
+ try:
75
+ data = json.loads(tf.read_text(encoding="utf-8"))
76
+ if data.get("metadata", {}).get("_internal"):
77
+ continue
78
+ tasks.append(data)
79
+ except (json.JSONDecodeError, OSError):
80
+ continue
81
+ return tasks
82
+
83
+
84
+ def generate_comm_graph(team_name: str) -> str:
85
+ """Generate a complete HTML comm graph for a team."""
86
+ team_dir = TEAMS_DIR / team_name
87
+ config_path = team_dir / "config.json"
88
+ if not config_path.exists():
89
+ return f"<html><body style='background:#080c10;color:#e6edf3;padding:2em'>Team '{_esc(team_name)}' nicht gefunden.</body></html>"
90
+
91
+ try:
92
+ config = json.loads(config_path.read_text(encoding="utf-8"))
93
+ except (json.JSONDecodeError, OSError):
94
+ return f"<html><body style='background:#080c10;color:#e6edf3;padding:2em'>Team '{_esc(team_name)}' config fehlerhaft.</body></html>"
95
+ members = config.get("members", [])
96
+ if not members:
97
+ return f"<html><body style='background:#080c10;color:#e6edf3;padding:2em'>Team '{_esc(team_name)}' hat keine Members.</body></html>"
98
+
99
+ # Separate lead from other members
100
+ lead = None
101
+ others = []
102
+ for m in members:
103
+ if m.get("agentType") == "team-lead" or m.get("name") == "team-lead":
104
+ lead = m
105
+ else:
106
+ others.append(m)
107
+ if not lead and members:
108
+ lead = members[0]
109
+ others = members[1:]
110
+
111
+ # Collect inbox data per member
112
+ member_data = []
113
+ for m in [lead] + others:
114
+ name = m.get("name", "?")
115
+ msgs = _load_inbox(team_dir, name)
116
+ model_key = m.get("model", "")
117
+ model_info = MODEL_BADGE.get(model_key, (model_key, "#8b949e"))
118
+ color = COLOR_MAP.get(m.get("color", ""), "#8b949e")
119
+ last_ts = msgs[-1].get("timestamp", "") if msgs else ""
120
+ member_data.append({
121
+ "name": name,
122
+ "model": model_info[0],
123
+ "model_color": model_info[1],
124
+ "color": color,
125
+ "agent_type": m.get("agentType", ""),
126
+ "msg_count": len(msgs),
127
+ "status": "working" if _is_recent(last_ts, 120) else "idle",
128
+ "last_msgs": msgs[-3:] if msgs else [],
129
+ "is_lead": m == lead,
130
+ "has_recent": _is_recent(last_ts, 300),
131
+ })
132
+
133
+ # Load tasks
134
+ tasks = _load_tasks(team_name)
135
+
136
+ # Collect all messages chronologically
137
+ all_msgs = []
138
+ for m in members:
139
+ name = m.get("name", "?")
140
+ for msg in _load_inbox(team_dir, name):
141
+ msg["_to"] = name
142
+ all_msgs.append(msg)
143
+ all_msgs.sort(key=lambda x: x.get("timestamp", ""))
144
+ recent_msgs = all_msgs[-10:]
145
+
146
+ # Layout: lead in center, others in circle
147
+ cx, cy = 400, 270
148
+ radius = 190
149
+ n = len(others)
150
+ positions = {}
151
+ positions[lead.get("name", "?")] = (cx, cy)
152
+ for i, m in enumerate(others):
153
+ angle = -math.pi / 2 + (2 * math.pi * i / max(n, 1))
154
+ x = cx + radius * math.cos(angle)
155
+ y = cy + radius * math.sin(angle)
156
+ positions[m.get("name", "?")] = (x, y)
157
+
158
+ lead_name = lead.get("name", "?")
159
+
160
+ # Build SVG bezier connections
161
+ svg_paths = []
162
+ for md in member_data:
163
+ if md["is_lead"]:
164
+ continue
165
+ lx, ly = positions[lead_name]
166
+ mx, my = positions[md["name"]]
167
+ # Control point: offset perpendicular to midpoint
168
+ midx, midy = (lx + mx) / 2, (ly + my) / 2
169
+ dx, dy = mx - lx, my - ly
170
+ length = math.sqrt(dx * dx + dy * dy) or 1
171
+ # Perpendicular offset (30px)
172
+ px, py = -dy / length * 30, dx / length * 30
173
+ cpx, cpy = midx + px, midy + py
174
+ active_class = "conn-active" if md["has_recent"] else "conn"
175
+ svg_paths.append(
176
+ f'<path class="{active_class}" id="path-{md["name"]}" '
177
+ f'data-from="{lead_name}" data-to="{md["name"]}" '
178
+ f'd="M {lx} {ly} Q {cpx:.1f} {cpy:.1f} {mx:.1f} {my:.1f}" '
179
+ f'stroke="{md["color"]}" stroke-width="2" fill="none" stroke-opacity="0.3"/>'
180
+ )
181
+
182
+ # Build node HTML
183
+ node_html_parts = []
184
+ for md in member_data:
185
+ x, y = positions[md["name"]]
186
+ assigned_task = next((t for t in tasks if t.get("owner") == md["name"]), None)
187
+ task_html = ""
188
+ if assigned_task:
189
+ st = assigned_task.get("status", "pending")
190
+ st_color = {"completed": "#98c379", "in_progress": "#e5c07b"}.get(st, "#8b949e")
191
+ desc_preview = _esc(assigned_task.get("description", "")[:100].replace('\n', ' '))
192
+ subj = _esc(assigned_task.get("subject", "")[:60])
193
+ task_html = f'''
194
+ <div class="node-task">
195
+ <span class="task-status" style="color:{st_color}">{st.upper().replace("_"," ")}</span>
196
+ {subj}
197
+ <div class="task-desc">{desc_preview}</div>
198
+ </div>'''
199
+
200
+ msg_html = ""
201
+ if md["last_msgs"]:
202
+ msg_items = ""
203
+ for msg in md["last_msgs"]:
204
+ sender = _esc(msg.get("from", "?"))
205
+ text = _esc(msg.get("text", "")[:80].replace("\n", " "))
206
+ msg_items += f'<div class="detail-msg"><b>{sender}:</b> {text}</div>'
207
+ msg_html = f'<div class="node-messages">{msg_items}</div>'
208
+
209
+ status_class = "status-working" if md["status"] == "working" else "status-idle"
210
+
211
+ safe_name = _esc(md['name'])
212
+ # Use data attribute for JS to avoid quote-breaking in onclick
213
+ node_html_parts.append(f'''
214
+ <div class="node" id="node-{safe_name}" data-name="{safe_name}"
215
+ style="left:{x-70}px;top:{y-40}px;--node-color:{md['color']}"
216
+ onclick="toggleDetail(this.dataset.name)"
217
+ onmouseenter="highlightConn(this.dataset.name,true)"
218
+ onmouseleave="highlightConn(this.dataset.name,false)">
219
+ <div class="node-header">
220
+ <span class="status-dot {status_class}" id="dot-{safe_name}"></span>
221
+ <span class="node-name">{safe_name}</span>
222
+ <span class="model-badge" style="background:{md['model_color']}20;color:{md['model_color']}">{md['model']}</span>
223
+ </div>
224
+ <div class="node-meta">
225
+ <span class="agent-type">{md['agent_type']}</span>
226
+ <span class="msg-badge" id="badge-{safe_name}">{md['msg_count']}</span>
227
+ </div>
228
+ <div class="node-detail" id="detail-{safe_name}">
229
+ <div class="detail-inner">
230
+ {task_html}
231
+ {msg_html}
232
+ </div>
233
+ </div>
234
+ </div>''')
235
+
236
+ # Task stream
237
+ order = {"in_progress": 0, "pending": 1, "completed": 2}
238
+ tasks.sort(key=lambda t: order.get(t.get("status", ""), 9))
239
+ task_stream_items = ""
240
+ for t in tasks[:8]:
241
+ st = t.get("status", "pending")
242
+ st_icon = {"completed": "&#10003;", "in_progress": "&#9679;"}.get(st, "&#9675;")
243
+ st_color = {"completed": "#98c379", "in_progress": "#e5c07b"}.get(st, "#8b949e")
244
+ owner = _esc(t.get("owner", ""))
245
+ task_stream_items += f'''
246
+ <div class="stream-item">
247
+ <span style="color:{st_color}">{st_icon}</span>
248
+ <span class="stream-subject">{_esc(t.get("subject","")[:50])}</span>
249
+ <span class="stream-owner">{owner}</span>
250
+ </div>'''
251
+
252
+ # Message log
253
+ msg_log_items = ""
254
+ for msg in reversed(recent_msgs):
255
+ sender = _esc(msg.get("from", "?"))
256
+ recipient = _esc(msg.get("_to", "?"))
257
+ text = _esc(msg.get("text", "")[:90].replace("\n", " "))
258
+ ts = msg.get("timestamp", "")
259
+ try:
260
+ time_fmt = datetime.fromisoformat(ts.replace("Z", "+00:00")).strftime("%H:%M")
261
+ except (ValueError, AttributeError):
262
+ time_fmt = "??:??"
263
+ msg_log_items += f'''
264
+ <div class="log-item">
265
+ <span class="log-time">{time_fmt}</span>
266
+ <span class="log-route">{sender} &rarr; {recipient}</span>
267
+ <span class="log-text">{text}</span>
268
+ </div>'''
269
+
270
+ html = f'''<!DOCTYPE html>
271
+ <html lang="de">
272
+ <head>
273
+ <meta charset="UTF-8">
274
+ <title>Comm Graph &mdash; {_esc(team_name)}</title>
275
+ <style>
276
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
277
+ :root {{
278
+ --bg: #080c10;
279
+ --surface: #0d1117;
280
+ --surface-2: #161b22;
281
+ --surface-3: #1c2333;
282
+ --border: rgba(255,255,255,0.06);
283
+ --border-bright: rgba(255,255,255,0.14);
284
+ --text: #e6edf3;
285
+ --text-dim: #8b949e;
286
+ --text-muted: #484f58;
287
+ --mono: 'Space Mono', 'SF Mono', 'Fira Code', monospace;
288
+ --sans: system-ui, -apple-system, sans-serif;
289
+ }}
290
+ body {{ font-family: var(--sans); background: var(--bg); color: var(--text); overflow-x: hidden; }}
291
+
292
+ .header {{ text-align: center; padding: 14px 0 6px; }}
293
+ .header h2 {{ font-size: 15px; font-weight: 600; letter-spacing: 0.5px; }}
294
+ .header .sub {{ font-size: 11px; color: var(--text-dim); margin-top: 2px; }}
295
+
296
+ /* ---- GRAPH AREA with dot grid ---- */
297
+ .graph-area {{
298
+ position: relative; width: 800px; height: 540px; margin: 0 auto;
299
+ background-image: radial-gradient(circle, rgba(255,255,255,0.025) 1px, transparent 1px);
300
+ background-size: 24px 24px;
301
+ border-top: 1px solid var(--border);
302
+ border-bottom: 1px solid var(--border);
303
+ }}
304
+ svg.connections {{
305
+ position: absolute; top: 0; left: 0; width: 800px; height: 540px;
306
+ pointer-events: none; z-index: 1;
307
+ }}
308
+
309
+ /* ---- CONNECTIONS ---- */
310
+ .conn, .conn-active {{
311
+ transition: stroke-opacity 0.3s, filter 0.3s;
312
+ }}
313
+ .conn-active {{
314
+ stroke-opacity: 0.5;
315
+ stroke-dasharray: 6 4;
316
+ animation: dash 1.5s linear infinite;
317
+ }}
318
+ .conn-highlight {{
319
+ stroke-opacity: 0.85 !important;
320
+ stroke-width: 3 !important;
321
+ filter: url(#glow);
322
+ }}
323
+ @keyframes dash {{
324
+ to {{ stroke-dashoffset: -20; }}
325
+ }}
326
+
327
+ /* ---- NODES ---- */
328
+ .node {{
329
+ position: absolute; width: 140px; padding: 10px 12px;
330
+ background: var(--surface); border: 1px solid var(--border);
331
+ border-radius: 10px; cursor: pointer; z-index: 2;
332
+ border-left: 3px solid var(--node-color);
333
+ transition: box-shadow 0.25s ease, transform 0.2s ease, border-color 0.3s;
334
+ }}
335
+ .node:hover {{
336
+ transform: translateY(-4px) scale(1.03);
337
+ border-color: var(--node-color);
338
+ box-shadow:
339
+ 0 8px 25px color-mix(in srgb, var(--node-color) 20%, transparent),
340
+ 0 0 0 1px color-mix(in srgb, var(--node-color) 15%, transparent);
341
+ }}
342
+ .node-header {{ display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }}
343
+ .node-name {{
344
+ font-weight: 600; font-size: 12px; flex: 1;
345
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
346
+ }}
347
+ .model-badge {{
348
+ font-size: 9px; padding: 1px 5px; border-radius: 4px;
349
+ font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;
350
+ }}
351
+ .status-dot {{
352
+ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
353
+ transition: background-color 0.4s, box-shadow 0.4s;
354
+ }}
355
+ .status-working {{
356
+ background: #98c379;
357
+ box-shadow: 0 0 8px #98c37988;
358
+ animation: pulse 2s ease-in-out infinite;
359
+ }}
360
+ .status-idle {{ background: #484f58; }}
361
+ @keyframes pulse {{ 0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.4; }} }}
362
+
363
+ .node-meta {{ display: flex; justify-content: space-between; align-items: center; }}
364
+ .agent-type {{ font-size: 10px; color: var(--text-dim); }}
365
+ .msg-badge {{
366
+ font-size: 10px; background: var(--surface-2); padding: 1px 6px;
367
+ border-radius: 8px; color: var(--text-dim); min-width: 18px; text-align: center;
368
+ }}
369
+
370
+ /* ---- NODE DETAIL (expandable) ---- */
371
+ .node-detail {{
372
+ display: grid; grid-template-rows: 0fr;
373
+ transition: grid-template-rows 0.3s ease-out, padding 0.3s;
374
+ overflow: hidden; margin-top: 0; padding-top: 0;
375
+ border-top: 1px solid transparent;
376
+ }}
377
+ .node-detail > .detail-inner {{ min-height: 0; }}
378
+ .node-detail.open {{
379
+ grid-template-rows: 1fr;
380
+ margin-top: 8px; padding-top: 8px;
381
+ border-top-color: var(--border);
382
+ }}
383
+ .node-task {{ font-size: 11px; margin-bottom: 6px; line-height: 1.4; }}
384
+ .task-status {{ font-size: 9px; font-weight: 700; margin-right: 4px; }}
385
+ .task-desc {{ color: var(--text-dim); font-size: 10px; margin-top: 2px; }}
386
+ .node-messages {{ font-size: 10px; }}
387
+ .detail-msg {{ margin-bottom: 4px; color: var(--text-dim); line-height: 1.3; }}
388
+ .detail-msg b {{ color: var(--text); font-weight: 500; }}
389
+
390
+ /* ---- BOTTOM PANELS ---- */
391
+ .bottom {{
392
+ display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;
393
+ padding: 14px 20px; max-width: 800px; margin: 0 auto;
394
+ }}
395
+ .section-title {{
396
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
397
+ letter-spacing: 1.2px; color: var(--text-muted); margin-bottom: 8px;
398
+ }}
399
+ .stream-item {{
400
+ display: flex; gap: 8px; align-items: baseline; font-size: 12px;
401
+ padding: 4px 0; border-bottom: 1px solid var(--border);
402
+ }}
403
+ .stream-subject {{ flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
404
+ .stream-owner {{ color: var(--text-dim); font-size: 11px; font-family: var(--mono); }}
405
+ .log-item {{
406
+ display: flex; gap: 8px; font-size: 11px; padding: 3px 0;
407
+ border-bottom: 1px solid var(--border);
408
+ }}
409
+ .log-time {{ color: var(--text-muted); flex-shrink: 0; font-family: var(--mono); font-size: 10px; }}
410
+ .log-route {{
411
+ color: var(--text-dim); flex-shrink: 0; width: 140px;
412
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
413
+ font-family: var(--mono); font-size: 10px;
414
+ }}
415
+ .log-text {{ flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
416
+
417
+ /* ---- EVENT TICKER ---- */
418
+ .event-item {{
419
+ display: flex; gap: 6px; align-items: baseline; font-size: 11px;
420
+ padding: 3px 0; border-bottom: 1px solid var(--border);
421
+ font-family: var(--mono);
422
+ animation: event-flash 0.6s ease-out;
423
+ }}
424
+ .event-time {{ color: var(--text-muted); flex-shrink: 0; font-size: 10px; }}
425
+ .event-type {{ flex-shrink: 0; font-size: 9px; font-weight: 600; padding: 1px 4px; border-radius: 3px; }}
426
+ .event-type-PostToolUse {{ background: #e5c07b20; color: #e5c07b; }}
427
+ .event-type-SubagentStart {{ background: #98c37920; color: #98c379; }}
428
+ .event-type-SubagentStop {{ background: #e06c7520; color: #e06c75; }}
429
+ .event-type-UserPromptSubmit {{ background: #61afef20; color: #61afef; }}
430
+ .event-detail {{ flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-dim); }}
431
+ @keyframes event-flash {{
432
+ 0% {{ background: rgba(255,255,255,0.06); }}
433
+ 100% {{ background: transparent; }}
434
+ }}
435
+ .activity-flash {{
436
+ animation: glow-flash 0.8s ease-out;
437
+ }}
438
+ @keyframes glow-flash {{
439
+ 0% {{ box-shadow: 0 0 20px var(--node-color); }}
440
+ 100% {{ box-shadow: none; }}
441
+ }}
442
+ </style>
443
+ </head>
444
+ <body>
445
+ <div class="header">
446
+ <h2>{_esc(team_name)}</h2>
447
+ <div class="sub">{len(members)} Agents &middot; {len(tasks)} Tasks &middot; {sum(1 for md in member_data if md["status"]=="working")} active</div>
448
+ </div>
449
+
450
+ <div class="graph-area" id="graph">
451
+ <svg class="connections" id="svg-conn">
452
+ <defs>
453
+ <filter id="glow">
454
+ <feGaussianBlur stdDeviation="4" result="blur"/>
455
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
456
+ </filter>
457
+ <filter id="particle-glow">
458
+ <feGaussianBlur stdDeviation="2.5" result="blur"/>
459
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
460
+ </filter>
461
+ </defs>
462
+ {"".join(svg_paths)}
463
+ </svg>
464
+ {"".join(node_html_parts)}
465
+ </div>
466
+
467
+ <div class="bottom">
468
+ <div>
469
+ <div class="section-title">Tasks</div>
470
+ <div id="task-stream">{task_stream_items}</div>
471
+ </div>
472
+ <div>
473
+ <div class="section-title">Messages</div>
474
+ <div id="msg-log">{msg_log_items}</div>
475
+ </div>
476
+ <div>
477
+ <div class="section-title">Live Events</div>
478
+ <div id="event-ticker"></div>
479
+ </div>
480
+ </div>
481
+
482
+ <script>
483
+ function toggleDetail(name) {{
484
+ var el = document.getElementById('detail-' + name);
485
+ if (el) el.classList.toggle('open');
486
+ }}
487
+
488
+ // Connection highlight on node hover — also highlights ALL paths for team-lead (W1 fix)
489
+ function highlightConn(name, on) {{
490
+ var path = document.getElementById('path-' + name);
491
+ if (path) {{
492
+ if (on) path.classList.add('conn-highlight');
493
+ else path.classList.remove('conn-highlight');
494
+ }} else {{
495
+ // Team-lead has no single path — highlight all connections
496
+ document.querySelectorAll('[id^="path-"]').forEach(function(p) {{
497
+ if (on) p.classList.add('conn-highlight');
498
+ else p.classList.remove('conn-highlight');
499
+ }});
500
+ }}
501
+ }}
502
+
503
+ // Escape HTML to prevent XSS in dynamic updates (C1 fix)
504
+ function esc(s) {{ var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }}
505
+
506
+ // Bezier point interpolation
507
+ function bezierPoint(t, x0, y0, cpx, cpy, x1, y1) {{
508
+ var u = 1 - t;
509
+ return {{
510
+ x: u*u*x0 + 2*u*t*cpx + t*t*x1,
511
+ y: u*u*y0 + 2*u*t*cpy + t*t*y1
512
+ }};
513
+ }}
514
+
515
+ // Parse path "M x0 y0 Q cpx cpy x1 y1" — with null guard (C2 fix)
516
+ function parsePath(pathEl) {{
517
+ var d = pathEl.getAttribute('d');
518
+ if (!d) return null;
519
+ var nums = d.match(/-?[\\d.]+/g);
520
+ if (!nums || nums.length < 6) return null;
521
+ nums = nums.map(Number);
522
+ return {{ x0: nums[0], y0: nums[1], cpx: nums[2], cpy: nums[3], x1: nums[4], y1: nums[5] }};
523
+ }}
524
+
525
+ // Particle with trail effect — cleanup on abort (W4 fix)
526
+ function animateParticle(memberName, reverse) {{
527
+ var pathEl = document.getElementById('path-' + memberName);
528
+ if (!pathEl) return;
529
+ var pp = parsePath(pathEl);
530
+ if (!pp) return;
531
+ var color = pathEl.getAttribute('stroke');
532
+ var svg = document.getElementById('svg-conn');
533
+ if (!svg) return;
534
+
535
+ var circles = [];
536
+ var sizes = [5, 3.5, 2.5, 1.5];
537
+ var opacities = [0.95, 0.6, 0.35, 0.15];
538
+ for (var i = 0; i < 4; i++) {{
539
+ var c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
540
+ c.setAttribute('r', sizes[i]);
541
+ c.setAttribute('fill', color);
542
+ c.setAttribute('opacity', '0');
543
+ if (i === 0) c.setAttribute('filter', 'url(#particle-glow)');
544
+ svg.appendChild(c);
545
+ circles.push(c);
546
+ }}
547
+
548
+ var start = null, dur = 1400;
549
+ var trailOffsets = [0, 0.04, 0.09, 0.15];
550
+ var cancelled = false;
551
+
552
+ function cleanup() {{ circles.forEach(function(c) {{ if (c.parentNode) c.remove(); }}); }}
553
+
554
+ function step(ts) {{
555
+ if (cancelled) {{ cleanup(); return; }}
556
+ if (!start) start = ts;
557
+ var progress = Math.min((ts - start) / dur, 1);
558
+
559
+ for (var i = 0; i < circles.length; i++) {{
560
+ var t = progress - trailOffsets[i];
561
+ if (t < 0 || t > 1) {{
562
+ circles[i].setAttribute('opacity', '0');
563
+ continue;
564
+ }}
565
+ var actualT = reverse ? 1 - t : t;
566
+ var pt = bezierPoint(actualT, pp.x0, pp.y0, pp.cpx, pp.cpy, pp.x1, pp.y1);
567
+ circles[i].setAttribute('cx', pt.x);
568
+ circles[i].setAttribute('cy', pt.y);
569
+ var fadeOut = 1 - progress * 0.5;
570
+ circles[i].setAttribute('opacity', opacities[i] * fadeOut);
571
+ }}
572
+
573
+ if (progress < 1) requestAnimationFrame(step);
574
+ else cleanup();
575
+ }}
576
+ requestAnimationFrame(step);
577
+
578
+ // Cancel on page unload to prevent orphaned circles (W4)
579
+ window.addEventListener('beforeunload', function() {{ cancelled = true; cleanup(); }}, {{ once: true }});
580
+ }}
581
+
582
+ // Initial demo particles
583
+ setTimeout(function() {{
584
+ var names = {json.dumps([md["name"] for md in member_data if not md["is_lead"]])};
585
+ names.forEach(function(n, i) {{
586
+ setTimeout(function() {{ animateParticle(n, Math.random() > 0.5); }}, i * 600);
587
+ }});
588
+ }}, 800);
589
+
590
+ // Track message counts for particle triggers
591
+ var lastMsgCounts = {json.dumps({md["name"]: md["msg_count"] for md in member_data})};
592
+
593
+ function updateGraph(data) {{
594
+ if (!data || !data.members) return;
595
+ data.members.forEach(function(m) {{
596
+ var dot = document.getElementById('dot-' + m.name);
597
+ if (dot) {{
598
+ dot.className = 'status-dot ' + (m.status === 'working' ? 'status-working' : 'status-idle');
599
+ }}
600
+ var badge = document.getElementById('badge-' + m.name);
601
+ if (badge) badge.textContent = m.msg_count;
602
+
603
+ // Fire particles for new messages
604
+ var prev = lastMsgCounts[m.name] || 0;
605
+ if (m.msg_count > prev) {{
606
+ var count = Math.min(m.msg_count - prev, 3);
607
+ for (var i = 0; i < count; i++) {{
608
+ (function(idx) {{
609
+ setTimeout(function() {{ animateParticle(m.name, true); }}, idx * 500);
610
+ }})(i);
611
+ }}
612
+ }}
613
+ lastMsgCounts[m.name] = m.msg_count;
614
+ }});
615
+
616
+ if (data.tasks) {{
617
+ var html = '';
618
+ var order = {{"in_progress":0,"pending":1,"completed":2}};
619
+ data.tasks.sort(function(a,b) {{ return (order[a.status]||9) - (order[b.status]||9); }});
620
+ data.tasks.slice(0,8).forEach(function(t) {{
621
+ var subj = (t.subject || '').substring(0,50);
622
+ var icon = t.status==='completed'?'&#10003;':t.status==='in_progress'?'&#9679;':'&#9675;';
623
+ var col = t.status==='completed'?'#98c379':t.status==='in_progress'?'#e5c07b':'#8b949e';
624
+ html += '<div class="stream-item"><span style="color:'+col+'">'+icon+'</span>'
625
+ + '<span class="stream-subject">'+esc(subj)+'</span>'
626
+ + '<span class="stream-owner">'+esc(t.owner||'')+'</span></div>';
627
+ }});
628
+ document.getElementById('task-stream').innerHTML = html;
629
+ }}
630
+
631
+ if (data.new_messages && data.new_messages.length) {{
632
+ var log = document.getElementById('msg-log');
633
+ data.new_messages.forEach(function(msg) {{
634
+ var div = document.createElement('div');
635
+ div.className = 'log-item';
636
+ div.innerHTML = '<span class="log-time">'+esc(msg.time||'')+'</span>'
637
+ + '<span class="log-route">'+esc(msg.sender||'')+' &rarr; '+esc(msg.recipient||'')+'</span>'
638
+ + '<span class="log-text">'+esc((msg.text||'').substring(0,90))+'</span>';
639
+ log.prepend(div);
640
+ }});
641
+ while (log.children.length > 10) log.removeChild(log.lastChild);
642
+ }}
643
+ }}
644
+
645
+ // --- LIVE EVENTS ---
646
+ var seenEventCount = 0;
647
+
648
+ function updateEvents(events) {{
649
+ if (!events || !events.length) return;
650
+ var ticker = document.getElementById('event-ticker');
651
+ if (!ticker) return;
652
+
653
+ // Only show new events (compare count)
654
+ var newEvents = events.slice(seenEventCount);
655
+ seenEventCount = events.length;
656
+
657
+ newEvents.forEach(function(ev) {{
658
+ var div = document.createElement('div');
659
+ div.className = 'event-item';
660
+ var typeClass = 'event-type-' + (ev.type || '').replace(/[^a-zA-Z]/g, '');
661
+ var detail = ev.tool || ev.agent || ev.session || '';
662
+ div.innerHTML = '<span class="event-time">' + esc(ev.time || '') + '</span>'
663
+ + '<span class="event-type ' + typeClass + '">' + esc(ev.type || '') + '</span>'
664
+ + '<span class="event-detail">' + esc(detail) + '</span>';
665
+ ticker.prepend(div);
666
+
667
+ // Trigger particle burst for PostToolUse
668
+ if (ev.type === 'PostToolUse') {{
669
+ var names = {json.dumps([md["name"] for md in member_data if not md["is_lead"]])};
670
+ var working = names.filter(function(n) {{
671
+ var dot = document.getElementById('dot-' + n);
672
+ return dot && dot.classList.contains('status-working');
673
+ }});
674
+ if (working.length > 0) {{
675
+ var target = working[Math.floor(Math.random() * working.length)];
676
+ animateParticle(target, Math.random() > 0.5);
677
+ }}
678
+ }}
679
+
680
+ // Flash node on SubagentStart
681
+ if (ev.type === 'SubagentStart' && ev.agent) {{
682
+ var node = document.getElementById('node-' + ev.agent);
683
+ if (node) {{
684
+ node.classList.add('activity-flash');
685
+ setTimeout(function() {{ node.classList.remove('activity-flash'); }}, 800);
686
+ }}
687
+ var dot = document.getElementById('dot-' + ev.agent);
688
+ if (dot) {{
689
+ dot.className = 'status-dot status-working';
690
+ }}
691
+ }}
692
+
693
+ // Dim node on SubagentStop
694
+ if (ev.type === 'SubagentStop' && ev.agent) {{
695
+ var dot = document.getElementById('dot-' + ev.agent);
696
+ if (dot) {{
697
+ dot.className = 'status-dot status-idle';
698
+ }}
699
+ }}
700
+ }});
701
+
702
+ // Keep ticker to max 20 items
703
+ while (ticker.children.length > 20) ticker.removeChild(ticker.lastChild);
704
+ }}
705
+ </script>
706
+ </body>
707
+ </html>'''
708
+ return html
test_monitor.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import unittest
3
+ from unittest.mock import patch
4
+
5
+ import monitor
6
+
7
+
8
+ class MonitorTests(unittest.TestCase):
9
+ def setUp(self) -> None:
10
+ monitor._cache.clear()
11
+ monitor._cache_ts.clear()
12
+ monitor._sidecar_refresh_in_flight = False
13
+
14
+ def tearDown(self) -> None:
15
+ monitor._cache.clear()
16
+ monitor._cache_ts.clear()
17
+ monitor._sidecar_refresh_in_flight = False
18
+
19
+ def test_get_sidecar_status_returns_fresh_cache_without_thread(self) -> None:
20
+ expected = {"running": True, "phase": "testing"}
21
+ monitor._cache["sidecar_status"] = expected
22
+ monitor._cache_ts["sidecar_status"] = time.time()
23
+
24
+ with patch("monitor.threading.Thread") as thread_cls:
25
+ result = monitor.get_sidecar_status()
26
+
27
+ self.assertEqual(result, expected)
28
+ thread_cls.assert_not_called()
29
+
30
+ def test_get_sidecar_status_returns_stale_value_and_starts_background_refresh(self) -> None:
31
+ stale = {"running": True, "phase": "implementation"}
32
+ monitor._cache["sidecar_status"] = stale
33
+ monitor._cache_ts["sidecar_status"] = 0
34
+
35
+ started = []
36
+
37
+ class FakeThread:
38
+ def __init__(self, target=None, args=(), daemon=None, name=None):
39
+ self.target = target
40
+ self.args = args
41
+ self.daemon = daemon
42
+ self.name = name
43
+
44
+ def start(self) -> None:
45
+ started.append((self.target, self.args, self.daemon, self.name))
46
+
47
+ with patch("monitor.threading.Thread", FakeThread):
48
+ result = monitor.get_sidecar_status()
49
+
50
+ self.assertEqual(result, stale)
51
+ self.assertEqual(len(started), 1)
52
+ self.assertTrue(monitor._sidecar_refresh_in_flight)
53
+
54
+ def test_get_sidecar_status_only_starts_one_refresh_thread(self) -> None:
55
+ monitor._cache["sidecar_status"] = {"running": False}
56
+ monitor._cache_ts["sidecar_status"] = 0
57
+ monitor._sidecar_refresh_in_flight = True
58
+
59
+ with patch("monitor.threading.Thread") as thread_cls:
60
+ result = monitor.get_sidecar_status()
61
+
62
+ self.assertEqual(result, {"running": False})
63
+ thread_cls.assert_not_called()
64
+
65
+
66
+ if __name__ == "__main__":
67
+ unittest.main()
test_shortcut_counter.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Tests for shortcut_counter_tab.py
3
+
4
+ Run with: python3 -m pytest test_shortcut_counter.py -v
5
+ or: python3 test_shortcut_counter.py
6
+ """
7
+
8
+ import sqlite3
9
+ import sys
10
+ import tempfile
11
+ import tomllib
12
+ from pathlib import Path
13
+ from unittest.mock import MagicMock, patch
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Test 1: Import check (no GTK required)
17
+ # ---------------------------------------------------------------------------
18
+
19
+ def test_import():
20
+ """Module must be importable without crashing.
21
+ GTK may not be available in CI — mock gi before importing."""
22
+ # Mock gi and GTK so the import doesn't need a display
23
+ gi_mock = MagicMock()
24
+ gtk_mock = MagicMock()
25
+ glib_mock = MagicMock()
26
+ pango_mock = MagicMock()
27
+
28
+ sys.modules.setdefault("gi", gi_mock)
29
+ sys.modules.setdefault("gi.repository", MagicMock())
30
+ sys.modules.setdefault("gi.repository.Gtk", gtk_mock)
31
+ sys.modules.setdefault("gi.repository.GLib", glib_mock)
32
+ sys.modules.setdefault("gi.repository.Pango", pango_mock)
33
+ sys.modules.setdefault("theme", MagicMock(
34
+ get_palette=lambda: {
35
+ "text": "#cdd6f4", "overlay": "#6c7086", "dim": "#585b70",
36
+ "green": "#a6e3a1", "yellow": "#f9e2af",
37
+ },
38
+ hex_to_pango_rgb=lambda h: (0, 0, 0),
39
+ ))
40
+
41
+ import importlib
42
+ import shortcut_counter_tab as sct
43
+ importlib.reload(sct) # reload with mocks in place
44
+
45
+ assert hasattr(sct, "build_shortcut_counter_tab"), "build_shortcut_counter_tab missing"
46
+ assert hasattr(sct, "refresh_shortcut_counter"), "refresh_shortcut_counter missing"
47
+ assert callable(sct.build_shortcut_counter_tab)
48
+ assert callable(sct.refresh_shortcut_counter)
49
+ print("PASS test_import")
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Test 2: _load_rows — DB missing → returns []
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def test_load_rows_missing_db():
57
+ """_load_rows must return [] when the DB file does not exist."""
58
+ import shortcut_counter_tab as sct
59
+
60
+ with patch.object(sct, "_DB_PATH", Path("/nonexistent/path/shortcuts.db")):
61
+ rows = sct._load_rows()
62
+
63
+ assert rows == [], f"Expected [], got {rows}"
64
+ print("PASS test_load_rows_missing_db")
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Test 3: _load_rows — real SQLite DB
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def test_load_rows_with_db():
72
+ """_load_rows must return dicts with expected keys from a real SQLite file."""
73
+ import shortcut_counter_tab as sct
74
+
75
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
76
+ db_path = Path(f.name)
77
+
78
+ try:
79
+ conn = sqlite3.connect(str(db_path))
80
+ conn.execute(
81
+ "CREATE TABLE shortcuts "
82
+ "(combo TEXT, count INTEGER, last_used TEXT, category TEXT)"
83
+ )
84
+ conn.execute(
85
+ "INSERT INTO shortcuts VALUES (?, ?, ?, ?)",
86
+ ("Ctrl+C", 42, "2026-03-04T10:00:00", "Editing"),
87
+ )
88
+ conn.execute(
89
+ "INSERT INTO shortcuts VALUES (?, ?, ?, ?)",
90
+ ("Ctrl+V", 5, None, "Editing"),
91
+ )
92
+ conn.commit()
93
+ conn.close()
94
+
95
+ with patch.object(sct, "_DB_PATH", db_path):
96
+ rows = sct._load_rows()
97
+
98
+ assert len(rows) == 2, f"Expected 2 rows, got {len(rows)}"
99
+ # Should be sorted by count DESC
100
+ assert rows[0]["combo"] == "Ctrl+C", f"First row should be Ctrl+C (count 42), got {rows[0]['combo']}"
101
+ assert rows[0]["count"] == 42
102
+ assert rows[1]["combo"] == "Ctrl+V"
103
+ # Keys present
104
+ for key in ("combo", "count", "last_used", "category"):
105
+ assert key in rows[0], f"Key '{key}' missing from row"
106
+
107
+ print("PASS test_load_rows_with_db")
108
+ finally:
109
+ db_path.unlink(missing_ok=True)
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Test 3b: _load_rows — TIMESTAMP column with ISO "T" values
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def test_load_rows_timestamp_iso_values():
117
+ """_load_rows must tolerate TIMESTAMP columns storing ISO strings."""
118
+ import shortcut_counter_tab as sct
119
+
120
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
121
+ db_path = Path(f.name)
122
+
123
+ try:
124
+ conn = sqlite3.connect(str(db_path))
125
+ conn.execute(
126
+ "CREATE TABLE shortcuts "
127
+ "(combo TEXT, count INTEGER, first_seen TIMESTAMP, last_used TIMESTAMP, category TEXT)"
128
+ )
129
+ conn.execute(
130
+ "INSERT INTO shortcuts (combo, count, first_seen, last_used, category) "
131
+ "VALUES (?, ?, ?, ?, ?)",
132
+ ("Super+L", 10, "2026-04-03T18:00:00", "2026-04-03T18:10:00", "COSMIC"),
133
+ )
134
+ conn.commit()
135
+ conn.close()
136
+
137
+ with patch.object(sct, "_DB_PATH", db_path):
138
+ rows = sct._load_rows()
139
+
140
+ assert len(rows) == 1, f"Expected 1 row, got {len(rows)}"
141
+ assert rows[0]["combo"] == "Super+L"
142
+ assert rows[0]["last_used"] == "2026-04-03T18:10:00"
143
+ print("PASS test_load_rows_timestamp_iso_values")
144
+ finally:
145
+ db_path.unlink(missing_ok=True)
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Test 4: _load_config — config missing → returns {}
150
+ # ---------------------------------------------------------------------------
151
+
152
+ def test_load_config_missing():
153
+ """_load_config must return {} when config.toml does not exist."""
154
+ import shortcut_counter_tab as sct
155
+
156
+ with patch.object(sct, "_CONFIG_PATH", Path("/nonexistent/config.toml")):
157
+ config = sct._load_config()
158
+
159
+ assert config == {}, f"Expected {{}}, got {config}"
160
+ print("PASS test_load_config_missing")
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Test 5: _load_config — real TOML file with [[items]] format
165
+ # ---------------------------------------------------------------------------
166
+
167
+ def test_load_config_with_file():
168
+ """_load_config must parse shortcuts and descriptions from a TOML config."""
169
+ import shortcut_counter_tab as sct
170
+
171
+ toml_content = b"""
172
+ [[shortcuts.Editing.items]]
173
+ combo = "Ctrl+C"
174
+ desc = "Kopieren"
175
+
176
+ [[shortcuts.Editing.items]]
177
+ combo = "Ctrl+V"
178
+ desc = "Einfuegen"
179
+
180
+ [[shortcuts.Editing.items]]
181
+ combo = "Ctrl+Z"
182
+ desc = "Rueckgaengig"
183
+
184
+ [[shortcuts.Navigation.items]]
185
+ combo = "Ctrl+Tab"
186
+ desc = "Tab wechseln"
187
+
188
+ [[shortcuts.Navigation.items]]
189
+ combo = "Alt+F4"
190
+ desc = "Fenster schliessen"
191
+ """
192
+ with tempfile.NamedTemporaryFile(suffix=".toml", delete=False) as f:
193
+ f.write(toml_content)
194
+ cfg_path = Path(f.name)
195
+
196
+ try:
197
+ with patch.object(sct, "_CONFIG_PATH", cfg_path):
198
+ config = sct._load_config()
199
+
200
+ assert "Editing" in config, f"Expected 'Editing' category, got {list(config.keys())}"
201
+ assert "Navigation" in config, f"Expected 'Navigation' category"
202
+ assert len(config["Editing"]) == 3, f"Expected 3 Editing items, got {len(config['Editing'])}"
203
+ assert len(config["Navigation"]) == 2, f"Expected 2 Navigation items, got {len(config['Navigation'])}"
204
+ # Check structure of items
205
+ first = config["Editing"][0]
206
+ assert first["combo"] == "Ctrl+C", f"Expected Ctrl+C, got {first['combo']}"
207
+ assert first["desc"] == "Kopieren", f"Expected 'Kopieren', got {first['desc']}"
208
+ # Check lookup works
209
+ all_combos = [it["combo"] for items in config.values() for it in items]
210
+ assert "Ctrl+C" in all_combos
211
+ assert "Alt+F4" in all_combos
212
+ print("PASS test_load_config_with_file")
213
+ finally:
214
+ cfg_path.unlink(missing_ok=True)
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Test 6: _status_label thresholds
219
+ # ---------------------------------------------------------------------------
220
+
221
+ def test_status_label():
222
+ """_status_label must return correct labels for count thresholds."""
223
+ import shortcut_counter_tab as sct
224
+
225
+ assert sct._status_label(0) == "Not Used"
226
+ assert sct._status_label(1) == "Learning"
227
+ assert sct._status_label(49) == "Learning"
228
+ assert sct._status_label(50) == "Mastered"
229
+ assert sct._status_label(100) == "Mastered"
230
+ print("PASS test_status_label")
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Test 7: _fmt_last_used edge cases
235
+ # ---------------------------------------------------------------------------
236
+
237
+ def test_fmt_last_used():
238
+ """_fmt_last_used must handle None, ISO strings, and datetime objects."""
239
+ from datetime import datetime
240
+ import shortcut_counter_tab as sct
241
+
242
+ assert sct._fmt_last_used(None) == "—"
243
+ assert sct._fmt_last_used("2026-03-04T10:30:00") == "04.03 10:30"
244
+
245
+ dt = datetime(2026, 3, 4, 10, 30)
246
+ assert sct._fmt_last_used(dt) == "04.03 10:30"
247
+ print("PASS test_fmt_last_used")
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Test 8: Widget creation (GTK required — skipped if no display)
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def test_build_tab_gtk():
255
+ """build_shortcut_counter_tab must return a Gtk.ScrolledWindow.
256
+
257
+ Skipped if no GTK display is available (e.g. headless CI).
258
+ Uses hasattr check to avoid isinstance() issues with mocked types.
259
+ """
260
+ # Clean up any mocks from test_import so real GTK is loaded
261
+ for key in list(sys.modules.keys()):
262
+ if key in ("gi.repository.Gtk", "gi.repository.GLib", "gi.repository.Pango",
263
+ "theme", "shortcut_counter_tab"):
264
+ sys.modules.pop(key, None)
265
+
266
+ try:
267
+ import gi
268
+ gi.require_version("Gtk", "3.0")
269
+ from gi.repository import Gtk
270
+ ok, _ = Gtk.init_check(None)
271
+ if not ok:
272
+ print("SKIP test_build_tab_gtk (Gtk.init_check failed — no display)")
273
+ return
274
+ except Exception as e:
275
+ print(f"SKIP test_build_tab_gtk (no GTK display: {e})")
276
+ return
277
+
278
+ import importlib
279
+ import shortcut_counter_tab
280
+ importlib.reload(shortcut_counter_tab)
281
+
282
+ tab = shortcut_counter_tab.build_shortcut_counter_tab()
283
+ # Check widget type by name to avoid isinstance() failures with stale mocks
284
+ type_name = type(tab).__name__
285
+ assert type_name == "ScrolledWindow", f"Expected ScrolledWindow, got {type_name}"
286
+ print("PASS test_build_tab_gtk")
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Runner
291
+ # ---------------------------------------------------------------------------
292
+
293
+ if __name__ == "__main__":
294
+ tests = [
295
+ test_import,
296
+ test_load_rows_missing_db,
297
+ test_load_rows_with_db,
298
+ test_load_rows_timestamp_iso_values,
299
+ test_load_config_missing,
300
+ test_load_config_with_file,
301
+ test_status_label,
302
+ test_fmt_last_used,
303
+ test_build_tab_gtk,
304
+ ]
305
+ failed = []
306
+ for t in tests:
307
+ try:
308
+ t()
309
+ except Exception as e:
310
+ print(f"FAIL {t.__name__}: {e}")
311
+ failed.append(t.__name__)
312
+
313
+ print()
314
+ if failed:
315
+ print(f"FAILED: {', '.join(failed)}")
316
+ sys.exit(1)
317
+ else:
318
+ print(f"All {len(tests)} tests passed.")
theme.py ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Theme detection + CSS generation for Claude Code Control Panel.
3
+
4
+ Supports COSMIC Desktop light/dark switching.
5
+ Detection: COSMIC is_dark config → GTK Settings fallback.
6
+ Palettes: Catppuccin Mocha (dark) / Catppuccin Latte (light).
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ import gi
12
+
13
+ gi.require_version("Gtk", "3.0")
14
+ from gi.repository import Gio, GLib, Gtk
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # COSMIC config path
18
+ # ---------------------------------------------------------------------------
19
+ _COSMIC_IS_DARK = Path.home() / ".config/cosmic/com.system76.CosmicTheme.Mode/v1/is_dark"
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Palettes
23
+ # ---------------------------------------------------------------------------
24
+ DARK = {
25
+ "name": "dark",
26
+ "bg": "#1e1e2e",
27
+ "mantle": "#181825",
28
+ "card": "#313244",
29
+ "border": "#45475a",
30
+ "text": "#cdd6f4",
31
+ "subtext1": "#bac2de",
32
+ "dim": "#6c7086",
33
+ "overlay": "#a6adc8",
34
+ "accent": "#89b4fa",
35
+ "mauve": "#cba6f7",
36
+ "lavender": "#b4befe",
37
+ "green": "#a6e3a1",
38
+ "yellow": "#f9e2af",
39
+ "red": "#f38ba8",
40
+ "teal": "#94e2d5",
41
+ "peach": "#fab387",
42
+ # Derived
43
+ "accent_hover_bg": "rgba(137,180,250,0.2)",
44
+ "mauve_hover_bg": "rgba(203,166,247,0.2)",
45
+ "green_hover_bg": "rgba(166,227,161,0.2)",
46
+ "yellow_hover_bg": "rgba(249,226,175,0.2)",
47
+ "resume_text": "#1e1e2e",
48
+ }
49
+
50
+ LIGHT = {
51
+ "name": "light",
52
+ "bg": "#eff1f5",
53
+ "mantle": "#e6e9ef",
54
+ "card": "#dce0e8",
55
+ "border": "#ccd0da",
56
+ "text": "#4c4f69",
57
+ "subtext1": "#5c5f77",
58
+ "dim": "#7c7f93",
59
+ "overlay": "#8c8fa1",
60
+ "accent": "#1e66f5",
61
+ "mauve": "#8839ef",
62
+ "lavender": "#7287fd",
63
+ "green": "#1a7f12",
64
+ "yellow": "#c65d06",
65
+ "red": "#d20f39",
66
+ "teal": "#127a80",
67
+ "peach": "#e64d00",
68
+ # Derived
69
+ "accent_hover_bg": "rgba(30,102,245,0.12)",
70
+ "mauve_hover_bg": "rgba(136,57,239,0.12)",
71
+ "green_hover_bg": "rgba(64,160,43,0.12)",
72
+ "yellow_hover_bg": "rgba(223,142,29,0.12)",
73
+ "resume_text": "#eff1f5",
74
+ }
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Detection
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def is_dark_mode() -> bool:
82
+ """Detect dark mode: COSMIC config first, GTK Settings fallback."""
83
+ try:
84
+ content = _COSMIC_IS_DARK.read_text().strip().lower()
85
+ return content == "true"
86
+ except (OSError, ValueError):
87
+ pass
88
+ settings = Gtk.Settings.get_default()
89
+ if settings is None:
90
+ return True # safe fallback
91
+ return settings.get_property("gtk-application-prefer-dark-theme")
92
+
93
+
94
+ def get_palette() -> dict[str, str]:
95
+ """Return the active color palette."""
96
+ return DARK if is_dark_mode() else LIGHT
97
+
98
+
99
+ def hex_to_pango_rgb(hex_color: str) -> tuple[int, int, int]:
100
+ """Convert '#rrggbb' to Pango 16-bit (r, g, b) tuple."""
101
+ return (
102
+ int(hex_color[1:3], 16) * 257,
103
+ int(hex_color[3:5], 16) * 257,
104
+ int(hex_color[5:7], 16) * 257,
105
+ )
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # CSS generation
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def build_css(p: dict[str, str] | None = None) -> bytes:
113
+ """Generate panel CSS from a palette dict. Auto-detects if p is None."""
114
+ if p is None:
115
+ p = get_palette()
116
+ return f"""
117
+ window {{
118
+ background-color: {p["bg"]};
119
+ }}
120
+
121
+ .panel-header {{
122
+ font-size: 18px;
123
+ font-weight: bold;
124
+ color: {p["text"]};
125
+ }}
126
+
127
+ .stat-value {{
128
+ font-size: 22px;
129
+ font-weight: bold;
130
+ color: {p["accent"]};
131
+ }}
132
+
133
+ .stat-label {{
134
+ font-size: 11px;
135
+ color: {p["overlay"]};
136
+ }}
137
+
138
+ .shortcut-btn {{
139
+ padding: 8px 12px;
140
+ border-radius: 8px;
141
+ background: {p["card"]};
142
+ color: {p["text"]};
143
+ border: 1px solid {p["border"]};
144
+ min-width: 90px;
145
+ }}
146
+
147
+ .shortcut-btn:hover {{
148
+ background: {p["border"]};
149
+ }}
150
+
151
+ .shortcut-service {{ border-color: {p["accent"]}; }}
152
+ .shortcut-service:hover {{ border-color: {p["accent"]}; background: {p["accent_hover_bg"]}; }}
153
+ .shortcut-docs {{ border-color: {p["yellow"]}; }}
154
+ .shortcut-docs:hover {{ border-color: {p["yellow"]}; background: {p["yellow_hover_bg"]}; }}
155
+ .shortcut-folder {{ border-color: {p["green"]}; }}
156
+ .shortcut-folder:hover {{ border-color: {p["green"]}; background: {p["green_hover_bg"]}; }}
157
+ .shortcut-config {{ border-color: {p["mauve"]}; }}
158
+ .shortcut-config:hover {{ border-color: {p["mauve"]}; background: {p["mauve_hover_bg"]}; }}
159
+
160
+ .session-row {{
161
+ padding: 8px 10px;
162
+ border-radius: 8px;
163
+ background: transparent;
164
+ border-left: 3px solid {p["mauve"]};
165
+ margin: 3px 0;
166
+ }}
167
+
168
+ .session-row:hover {{
169
+ background: {p["card"]};
170
+ }}
171
+
172
+ .session-row-working {{
173
+ border-left: 3px solid {p["yellow"]};
174
+ background: {p["yellow_hover_bg"]};
175
+ }}
176
+
177
+ .session-row-working:hover {{
178
+ background: {p["yellow_hover_bg"]};
179
+ }}
180
+
181
+ .session-row-ready {{
182
+ border-left: 3px solid {p["green"]};
183
+ background: {p["green_hover_bg"]};
184
+ }}
185
+
186
+ .session-row-ready:hover {{
187
+ background: {p["green_hover_bg"]};
188
+ }}
189
+
190
+ .session-working-badge {{
191
+ background: {p["yellow_hover_bg"]};
192
+ color: {p["yellow"]};
193
+ border-radius: 12px;
194
+ padding: 2px 8px;
195
+ font-size: 10px;
196
+ font-weight: bold;
197
+ }}
198
+
199
+ .session-ready-badge {{
200
+ background: {p["green_hover_bg"]};
201
+ color: {p["green"]};
202
+ border-radius: 12px;
203
+ padding: 2px 8px;
204
+ font-size: 10px;
205
+ font-weight: bold;
206
+ }}
207
+
208
+ .session-project {{
209
+ font-size: 13px;
210
+ font-weight: bold;
211
+ color: {p["text"]};
212
+ }}
213
+
214
+ .session-preview {{
215
+ font-size: 11px;
216
+ color: {p["subtext1"]};
217
+ }}
218
+
219
+ .session-meta {{
220
+ font-size: 10px;
221
+ color: {p["dim"]};
222
+ letter-spacing: 0.02em;
223
+ }}
224
+
225
+ .session-resume {{
226
+ padding: 4px 14px;
227
+ border-radius: 6px;
228
+ background: {p["accent"]};
229
+ color: {p["resume_text"]};
230
+ font-weight: bold;
231
+ font-size: 11px;
232
+ border: none;
233
+ }}
234
+
235
+ .session-resume:hover {{
236
+ background: {p["lavender"]};
237
+ }}
238
+
239
+ .session-stats {{
240
+ border-radius: 20px;
241
+ background: {p["card"]};
242
+ padding: 6px 14px;
243
+ color: {p["teal"]};
244
+ font-size: 11px;
245
+ letter-spacing: 0.02em;
246
+ }}
247
+
248
+ .section-frame {{
249
+ border: 1px solid {p["border"]};
250
+ border-radius: 8px;
251
+ padding: 10px;
252
+ }}
253
+
254
+ .section-title {{
255
+ font-weight: bold;
256
+ color: {p["text"]};
257
+ font-size: 13px;
258
+ }}
259
+
260
+ .monitor-value {{
261
+ font-family: monospace;
262
+ color: {p["green"]};
263
+ }}
264
+
265
+ .cost-value {{
266
+ font-family: monospace;
267
+ font-size: 20px;
268
+ font-weight: bold;
269
+ color: {p["yellow"]};
270
+ }}
271
+
272
+ .status-bar {{
273
+ background: {p["mantle"]};
274
+ padding: 6px 12px;
275
+ }}
276
+
277
+ .status-saved {{
278
+ color: {p["green"]};
279
+ font-weight: bold;
280
+ }}
281
+
282
+ .status-error {{
283
+ color: {p["red"]};
284
+ font-weight: bold;
285
+ }}
286
+
287
+ notebook header {{
288
+ background: {p["mantle"]};
289
+ }}
290
+
291
+ notebook tab {{
292
+ padding: 8px 16px;
293
+ color: {p["overlay"]};
294
+ }}
295
+
296
+ notebook tab:checked {{
297
+ color: {p["accent"]};
298
+ border-bottom: 2px solid {p["accent"]};
299
+ }}
300
+
301
+ .watcher-dot-critical {{ color: {p["red"]}; font-size: 16px; }}
302
+ .watcher-dot-warning {{ color: {p["yellow"]}; font-size: 16px; }}
303
+ .watcher-dot-info {{ color: {p["accent"]}; font-size: 16px; }}
304
+ .watcher-dot-inactive {{ color: {p["dim"]}; font-size: 16px; }}
305
+ .watcher-name {{ font-family: monospace; font-size: 11px; color: {p["subtext1"]}; }}
306
+
307
+ .phase-badge {{
308
+ font-family: monospace;
309
+ font-size: 12px;
310
+ font-weight: bold;
311
+ padding: 4px 10px;
312
+ border-radius: 12px;
313
+ color: {p["text"]};
314
+ }}
315
+ .phase-badge-exploration {{ background: rgba(137,180,250,0.25); color: {p["accent"]}; }}
316
+ .phase-badge-implementation {{ background: rgba(166,227,161,0.25); color: {p["green"]}; }}
317
+ .phase-badge-testing {{ background: rgba(249,226,175,0.25); color: {p["yellow"]}; }}
318
+ .phase-badge-debugging {{ background: rgba(243,139,168,0.25); color: {p["red"]}; }}
319
+
320
+ .signal-card {{
321
+ border-radius: 8px;
322
+ background: {p["card"]};
323
+ border: 1px solid {p["border"]};
324
+ padding: 12px;
325
+ }}
326
+ .cost-value {{
327
+ font-family: monospace;
328
+ font-size: 20px;
329
+ font-weight: bold;
330
+ color: {p["yellow"]};
331
+ }}
332
+ .mon-stat-value {{
333
+ font-family: monospace;
334
+ font-size: 16px;
335
+ font-weight: bold;
336
+ color: {p["accent"]};
337
+ }}
338
+ .stat-sublabel {{
339
+ font-size: 11px;
340
+ color: {p["subtext1"]};
341
+ }}
342
+
343
+ .sc-card {{
344
+ border-radius: 8px;
345
+ background: {p["card"]};
346
+ border: 1px solid {p["border"]};
347
+ padding: 10px;
348
+ margin: 4px 0;
349
+ }}
350
+
351
+ .sc-badge-mastered {{
352
+ background: {p["green_hover_bg"]};
353
+ color: {p["green"]};
354
+ border-radius: 12px;
355
+ padding: 2px 8px;
356
+ font-size: 10px;
357
+ font-weight: bold;
358
+ }}
359
+
360
+ .sc-badge-learning {{
361
+ background: {p["yellow_hover_bg"]};
362
+ color: {p["yellow"]};
363
+ border-radius: 12px;
364
+ padding: 2px 8px;
365
+ font-size: 10px;
366
+ font-weight: bold;
367
+ }}
368
+
369
+ .sc-badge-unused {{
370
+ background: rgba(243,139,168,0.2);
371
+ color: {p["red"]};
372
+ border-radius: 12px;
373
+ padding: 2px 8px;
374
+ font-size: 10px;
375
+ font-weight: bold;
376
+ }}
377
+
378
+ .sc-learn-card {{
379
+ border-left: 3px solid {p["red"]};
380
+ padding: 8px 12px;
381
+ border-radius: 6px;
382
+ background: {p["card"]};
383
+ margin: 3px 0;
384
+ }}
385
+
386
+ .sc-learn-card:hover {{
387
+ background: {p["border"]};
388
+ }}
389
+
390
+ /* --- Log Viewer --- */
391
+ .log-toolbar {{
392
+ background: {p["mantle"]};
393
+ border-radius: 8px;
394
+ padding: 6px 10px;
395
+ }}
396
+
397
+ .log-card {{
398
+ background: {p["card"]};
399
+ border-radius: 6px;
400
+ border: 1px solid {p["border"]};
401
+ padding: 6px 10px;
402
+ margin: 1px 0;
403
+ }}
404
+
405
+ .log-card:hover {{
406
+ background: {p["border"]};
407
+ }}
408
+
409
+ .log-card-tool {{ border-left: 3px solid {p["green"]}; }}
410
+ .log-card-llm {{ border-left: 3px solid {p["accent"]}; }}
411
+ .log-card-error {{ border-left: 3px solid {p["red"]}; }}
412
+ .log-card-hook {{ border-left: 3px solid {p["mauve"]}; }}
413
+
414
+ .log-time {{
415
+ font-family: monospace;
416
+ font-size: 10px;
417
+ color: {p["dim"]};
418
+ }}
419
+
420
+ .log-tool-name {{
421
+ font-family: monospace;
422
+ font-size: 11px;
423
+ font-weight: bold;
424
+ color: {p["text"]};
425
+ }}
426
+
427
+ .log-detail {{
428
+ font-size: 11px;
429
+ color: {p["subtext1"]};
430
+ }}
431
+
432
+ .log-cost {{
433
+ font-family: monospace;
434
+ font-size: 11px;
435
+ color: {p["yellow"]};
436
+ }}
437
+
438
+ .log-status-ok {{ color: {p["green"]}; }}
439
+ .log-status-error {{ color: {p["red"]}; }}
440
+ .log-status-neutral {{ color: {p["dim"]}; }}
441
+
442
+ .log-stats {{
443
+ font-size: 11px;
444
+ color: {p["overlay"]};
445
+ }}
446
+
447
+ /* --- Swarm Tab --- */
448
+ .swarm-toolbar {{
449
+ background: {p["mantle"]};
450
+ border-radius: 8px;
451
+ padding: 6px 10px;
452
+ }}
453
+
454
+ .swarm-btn {{
455
+ background: {p["card"]};
456
+ border-radius: 6px;
457
+ border: 1px solid {p["border"]};
458
+ padding: 4px 12px;
459
+ color: {p["text"]};
460
+ }}
461
+
462
+ .swarm-btn:hover {{
463
+ background: {p["border"]};
464
+ }}
465
+
466
+ .swarm-status-connected {{
467
+ color: {p["green"]};
468
+ font-weight: bold;
469
+ }}
470
+
471
+ .swarm-status-disconnected {{
472
+ color: {p["red"]};
473
+ font-weight: bold;
474
+ }}
475
+
476
+ .swarm-url {{
477
+ font-family: monospace;
478
+ font-size: 11px;
479
+ color: {p["dim"]};
480
+ }}
481
+
482
+ /* --- Swarm Native Tab --- */
483
+ .swarm-task-row {{
484
+ padding: 4px 8px;
485
+ border-radius: 6px;
486
+ background: transparent;
487
+ margin: 1px 0;
488
+ }}
489
+
490
+ .swarm-task-row:hover {{
491
+ background: {p["card"]};
492
+ }}
493
+
494
+ .swarm-badge-progress {{
495
+ color: {p["accent"]};
496
+ font-weight: bold;
497
+ font-size: 11px;
498
+ }}
499
+
500
+ .swarm-badge-completed {{
501
+ color: {p["green"]};
502
+ font-weight: bold;
503
+ font-size: 11px;
504
+ }}
505
+
506
+ .swarm-badge-pending {{
507
+ color: {p["dim"]};
508
+ font-weight: bold;
509
+ font-size: 11px;
510
+ }}
511
+
512
+ .swarm-message {{
513
+ padding: 2px 8px;
514
+ border-radius: 4px;
515
+ background: transparent;
516
+ margin: 1px 0;
517
+ }}
518
+
519
+ .swarm-message:hover {{
520
+ background: {p["card"]};
521
+ }}
522
+
523
+ .session-snippet {{
524
+ font-family: monospace;
525
+ font-size: 10px;
526
+ color: {p["subtext1"]};
527
+ padding: 2px 0;
528
+ }}
529
+
530
+ .session-match-badge {{
531
+ background: {p["accent_hover_bg"]};
532
+ color: {p["accent"]};
533
+ border-radius: 12px;
534
+ padding: 2px 8px;
535
+ font-size: 10px;
536
+ font-weight: bold;
537
+ }}
538
+
539
+ .session-row-search {{
540
+ border-left: 3px solid {p["accent"]};
541
+ }}
542
+
543
+ /* --- Monitor Tab Enhancements --- */
544
+ .mon-tool-bar {{
545
+ min-height: 8px;
546
+ }}
547
+ .mon-tool-bar trough {{
548
+ min-height: 8px;
549
+ background: {p["border"]};
550
+ border-radius: 4px;
551
+ }}
552
+ .mon-tool-bar progress {{
553
+ min-height: 8px;
554
+ background: {p["accent"]};
555
+ border-radius: 4px;
556
+ }}
557
+ .mon-expander {{
558
+ color: {p["subtext1"]};
559
+ font-size: 11px;
560
+ }}
561
+ .mon-expander title {{
562
+ color: {p["subtext1"]};
563
+ }}
564
+ """.encode()
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Theme watcher
569
+ # ---------------------------------------------------------------------------
570
+
571
+ _css_provider: Gtk.CssProvider | None = None
572
+ _file_monitor = None # prevent GC
573
+
574
+
575
+ def apply_theme(provider: Gtk.CssProvider | None = None) -> None:
576
+ """(Re-)apply CSS based on current theme. Stores provider for reuse."""
577
+ global _css_provider
578
+ if provider is not None:
579
+ _css_provider = provider
580
+ if _css_provider is None:
581
+ return
582
+ _css_provider.load_from_data(build_css())
583
+
584
+
585
+ def setup_theme_watcher(provider: Gtk.CssProvider) -> None:
586
+ """Watch for COSMIC dark/light changes and re-apply CSS automatically."""
587
+ global _css_provider, _file_monitor
588
+ _css_provider = provider
589
+
590
+ # Watch COSMIC config file (or parent dir if file doesn't exist yet)
591
+ if _COSMIC_IS_DARK.exists():
592
+ gio_file = Gio.File.new_for_path(str(_COSMIC_IS_DARK))
593
+ try:
594
+ _file_monitor = gio_file.monitor_file(Gio.FileMonitorFlags.NONE, None)
595
+ _file_monitor.connect("changed", _on_cosmic_theme_changed)
596
+ except Exception:
597
+ pass
598
+ elif _COSMIC_IS_DARK.parent.exists():
599
+ # Watch parent dir so we detect when the file is created
600
+ gio_dir = Gio.File.new_for_path(str(_COSMIC_IS_DARK.parent))
601
+ try:
602
+ _file_monitor = gio_dir.monitor_directory(Gio.FileMonitorFlags.NONE, None)
603
+ _file_monitor.connect("changed", _on_cosmic_theme_changed)
604
+ except Exception:
605
+ pass
606
+
607
+ # Also watch GTK settings as fallback
608
+ settings = Gtk.Settings.get_default()
609
+ if settings:
610
+ settings.connect("notify::gtk-application-prefer-dark-theme", _on_gtk_theme_changed)
611
+ settings.connect("notify::gtk-theme-name", _on_gtk_theme_changed)
612
+
613
+
614
+ def _on_cosmic_theme_changed(_monitor, _file, _other, event_type) -> None:
615
+ if event_type in (Gio.FileMonitorEvent.CHANGES_DONE_HINT, Gio.FileMonitorEvent.CREATED):
616
+ GLib.idle_add(apply_theme)
617
+
618
+
619
+ def _on_gtk_theme_changed(*_args) -> None:
620
+ GLib.idle_add(apply_theme)
tools-gui/main.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Claude Code Tools — GTK3 standalone GUI.
3
+
4
+ Session history, cost tracking, and tool/skill usage analytics.
5
+ Data source: monitor.py from ClaudeCodePanel (TTL-cached, 30s).
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import gi
12
+
13
+ gi.require_version("Gtk", "3.0")
14
+ from gi.repository import GLib, Gtk
15
+
16
+ # Import shared base
17
+ sys.path.insert(0, str(Path.home() / "Projekte" / "shared-gui"))
18
+ from gui_base import BaseApp
19
+
20
+ # Import monitor for status bar info
21
+ sys.path.insert(0, str(Path.home() / "Projekte" / "ClaudeCodePanel"))
22
+ from monitor import get_daily_cost, format_cost, get_recent_sessions
23
+
24
+ from tabs.sessions_tab import SessionsTab
25
+ from tabs.costs_tab import CostsTab
26
+ from tabs.tools_tab import ToolsTab
27
+
28
+
29
+ class ToolsApp(BaseApp):
30
+ """Main Claude Code Tools application."""
31
+
32
+ def __init__(self):
33
+ super().__init__("Claude Code Tools", 800, 600, icon_name="utilities-system-monitor")
34
+
35
+ self._sessions_tab = SessionsTab()
36
+ self._costs_tab = CostsTab()
37
+ self._tools_tab = ToolsTab()
38
+
39
+ self._build_ui()
40
+
41
+ # Initial load
42
+ GLib.idle_add(self._refresh_all)
43
+
44
+ # Auto-refresh every 30 seconds (matches monitor.py TTL cache)
45
+ self.start_refresh(30, self._refresh_all)
46
+
47
+ def _build_ui(self):
48
+ # Refresh button in header
49
+ ref_btn = Gtk.Button(label="Refresh")
50
+ ref_btn.get_style_context().add_class("shortcut-btn")
51
+ ref_btn.connect("clicked", lambda _: self._refresh_all())
52
+ self.add_header_widget(ref_btn)
53
+
54
+ # Notebook with 3 tabs
55
+ notebook = Gtk.Notebook()
56
+ notebook.append_page(self._sessions_tab, Gtk.Label(label="Sessions"))
57
+ notebook.append_page(self._costs_tab, Gtk.Label(label="Costs"))
58
+ notebook.append_page(self._tools_tab, Gtk.Label(label="Tools"))
59
+
60
+ self.content_box.pack_start(notebook, True, True, 0)
61
+
62
+ def _refresh_all(self) -> bool:
63
+ """Refresh all tabs and status bar."""
64
+ self._sessions_tab.refresh()
65
+ self._costs_tab.refresh()
66
+ self._tools_tab.refresh()
67
+
68
+ # Update status bar
69
+ daily = get_daily_cost()
70
+ cost = format_cost(daily.get("cost_estimate_usd", 0))
71
+ sessions = get_recent_sessions(n=100)
72
+ self.set_status(f"{len(sessions)} sessions | {cost} today")
73
+
74
+ return True # keep timer
75
+
76
+
77
+ def main():
78
+ app = ToolsApp()
79
+ app.run()
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
tools-gui/tabs/__init__.py ADDED
File without changes
tools-gui/tabs/costs_tab.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Costs Tab — Daily cost stats + 7-day bar chart (Cairo) + tool rankings."""
3
+
4
+ import math
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import gi
9
+
10
+ gi.require_version("Gtk", "3.0")
11
+ from gi.repository import GLib, Gtk
12
+
13
+ sys.path.insert(0, str(Path.home() / "Projekte" / "ClaudeCodePanel"))
14
+ from theme import get_palette
15
+ from monitor import get_daily_cost, get_usage_timeline, get_provider_costs, get_top_tools, format_cost
16
+
17
+
18
+ class CostsTab(Gtk.Box):
19
+ """Cost overview with stats, bar chart, and tool rankings."""
20
+
21
+ def __init__(self):
22
+ super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
23
+ self.set_margin_top(12)
24
+ self.set_margin_start(12)
25
+ self.set_margin_end(12)
26
+
27
+ self._stat_labels: dict[str, Gtk.Label] = {}
28
+ self._timeline_data: list[dict] = []
29
+ self._chart_area = None
30
+ self._tools_box = None
31
+ self._providers_box = None
32
+
33
+ self._build_ui()
34
+
35
+ def _build_ui(self):
36
+ p = get_palette()
37
+
38
+ # --- Stats row ---
39
+ stats_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
40
+ stats_box.get_style_context().add_class("base-card")
41
+
42
+ for key, label in [("today", "Today"), ("calls", "Calls"), ("tools", "Tools")]:
43
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
44
+ val_lbl = Gtk.Label(label="--")
45
+ val_lbl.get_style_context().add_class("stat-value" if key == "today" else "monitor-value")
46
+ vbox.pack_start(val_lbl, False, False, 0)
47
+ name_lbl = Gtk.Label(label=label)
48
+ name_lbl.get_style_context().add_class("stat-label")
49
+ vbox.pack_start(name_lbl, False, False, 0)
50
+ stats_box.pack_start(vbox, True, True, 0)
51
+ self._stat_labels[key] = val_lbl
52
+
53
+ self.pack_start(stats_box, False, False, 0)
54
+
55
+ # --- Bar chart (7 days) ---
56
+ chart_label = Gtk.Label()
57
+ chart_label.set_markup('<b>Last 7 Days</b>')
58
+ chart_label.set_halign(Gtk.Align.START)
59
+ chart_label.get_style_context().add_class("section-title")
60
+ self.pack_start(chart_label, False, False, 0)
61
+
62
+ self._chart_area = Gtk.DrawingArea()
63
+ self._chart_area.set_size_request(-1, 120)
64
+ self._chart_area.connect("draw", self._draw_chart)
65
+ self.pack_start(self._chart_area, False, False, 0)
66
+
67
+ # --- Provider costs ---
68
+ prov_label = Gtk.Label()
69
+ prov_label.set_markup('<b>Provider Costs</b>')
70
+ prov_label.set_halign(Gtk.Align.START)
71
+ prov_label.get_style_context().add_class("section-title")
72
+ self.pack_start(prov_label, False, False, 0)
73
+
74
+ self._providers_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
75
+ self._providers_box.get_style_context().add_class("base-card")
76
+ self.pack_start(self._providers_box, False, False, 0)
77
+
78
+ # --- Top tools ---
79
+ tools_label = Gtk.Label()
80
+ tools_label.set_markup('<b>Top Tools</b>')
81
+ tools_label.set_halign(Gtk.Align.START)
82
+ tools_label.get_style_context().add_class("section-title")
83
+ self.pack_start(tools_label, False, False, 0)
84
+
85
+ self._tools_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
86
+ self._tools_box.get_style_context().add_class("base-card")
87
+ self.pack_start(self._tools_box, False, False, 0)
88
+
89
+ def refresh(self) -> None:
90
+ """Reload cost data from monitor.py."""
91
+ p = get_palette()
92
+
93
+ # Daily cost
94
+ daily = get_daily_cost()
95
+ cost = daily.get("cost_estimate_usd", 0)
96
+ self._stat_labels["today"].set_text(format_cost(cost))
97
+ self._stat_labels["calls"].set_text(str(daily.get("total_calls", 0)))
98
+ self._stat_labels["tools"].set_text(str(daily.get("unique_tools", 0)))
99
+
100
+ # Timeline for chart
101
+ self._timeline_data = get_usage_timeline()
102
+ self._chart_area.queue_draw()
103
+
104
+ # Provider costs
105
+ for child in self._providers_box.get_children():
106
+ self._providers_box.remove(child)
107
+
108
+ providers = get_provider_costs()
109
+ if providers:
110
+ for prov, cost_val in sorted(providers.items(), key=lambda x: -x[1]):
111
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
112
+ name_lbl = Gtk.Label()
113
+ name_lbl.set_markup(f'<tt>{GLib.markup_escape_text(prov)}</tt>')
114
+ name_lbl.set_xalign(0)
115
+ name_lbl.set_width_chars(15)
116
+ row.pack_start(name_lbl, False, False, 0)
117
+ cost_lbl = Gtk.Label()
118
+ cost_lbl.set_markup(
119
+ f'<span foreground="{p["yellow"]}" font_family="monospace">{format_cost(cost_val)}</span>'
120
+ )
121
+ cost_lbl.set_halign(Gtk.Align.END)
122
+ row.pack_end(cost_lbl, False, False, 0)
123
+ self._providers_box.pack_start(row, False, False, 0)
124
+ else:
125
+ lbl = Gtk.Label()
126
+ lbl.set_markup(f'<span foreground="{p["dim"]}">No cost data yet</span>')
127
+ self._providers_box.pack_start(lbl, False, False, 0)
128
+
129
+ self._providers_box.show_all()
130
+
131
+ # Top tools
132
+ for child in self._tools_box.get_children():
133
+ self._tools_box.remove(child)
134
+
135
+ top = get_top_tools(n=8)
136
+ if top:
137
+ for tool_name, count in top:
138
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
139
+ name_lbl = Gtk.Label()
140
+ name_lbl.set_markup(f'<tt>{GLib.markup_escape_text(tool_name)}</tt>')
141
+ name_lbl.set_xalign(0)
142
+ name_lbl.set_width_chars(20)
143
+ row.pack_start(name_lbl, False, False, 0)
144
+ count_lbl = Gtk.Label()
145
+ count_lbl.set_markup(
146
+ f'<span foreground="{p["accent"]}" font_family="monospace">{count}</span>'
147
+ )
148
+ row.pack_end(count_lbl, False, False, 0)
149
+ self._tools_box.pack_start(row, False, False, 0)
150
+ else:
151
+ lbl = Gtk.Label()
152
+ lbl.set_markup(f'<span foreground="{p["dim"]}">No tool data yet</span>')
153
+ self._tools_box.pack_start(lbl, False, False, 0)
154
+
155
+ self._tools_box.show_all()
156
+
157
+ def _draw_chart(self, widget, cr):
158
+ """Draw 7-day bar chart using Cairo."""
159
+ p = get_palette()
160
+ width = widget.get_allocated_width()
161
+ height = widget.get_allocated_height()
162
+
163
+ data = list(reversed(self._timeline_data)) # oldest first for left-to-right
164
+ if not data:
165
+ # Draw placeholder
166
+ cr.set_source_rgba(*_hex_rgb(p["dim"]), 0.5)
167
+ cr.select_font_face("monospace", 0, 0)
168
+ cr.set_font_size(11)
169
+ cr.move_to(width / 2 - 40, height / 2)
170
+ cr.show_text("No data yet")
171
+ return False
172
+
173
+ n = len(data)
174
+ max_cost = max((d.get("cost_est", 0) for d in data), default=1) or 0.01
175
+ bar_width = max(20, (width - 40) / n - 8)
176
+ gap = 8
177
+
178
+ x_start = 20
179
+ chart_height = height - 30 # leave room for labels
180
+
181
+ for i, d in enumerate(data):
182
+ cost = d.get("cost_est", 0)
183
+ bar_h = max(2, (cost / max_cost) * (chart_height - 10))
184
+ x = x_start + i * (bar_width + gap)
185
+ y = chart_height - bar_h
186
+
187
+ # Bar
188
+ r, g, b = _hex_rgb(p["accent"])
189
+ cr.set_source_rgba(r, g, b, 0.7)
190
+ cr.rectangle(x, y, bar_width, bar_h)
191
+ cr.fill()
192
+
193
+ # Date label
194
+ cr.set_source_rgba(*_hex_rgb(p["dim"]), 1)
195
+ cr.select_font_face("monospace", 0, 0)
196
+ cr.set_font_size(9)
197
+ date_str = d.get("date", "")[-5:] # MM-DD
198
+ cr.move_to(x, height - 4)
199
+ cr.show_text(date_str)
200
+
201
+ # Cost label on top of bar
202
+ cr.set_source_rgba(*_hex_rgb(p["text"]), 1)
203
+ cr.set_font_size(9)
204
+ cr.move_to(x, y - 3)
205
+ cr.show_text(format_cost(cost))
206
+
207
+ return False
208
+
209
+
210
+ def _hex_rgb(hex_color: str) -> tuple[float, float, float]:
211
+ """Convert #rrggbb to (r, g, b) floats 0-1."""
212
+ return (
213
+ int(hex_color[1:3], 16) / 255,
214
+ int(hex_color[3:5], 16) / 255,
215
+ int(hex_color[5:7], 16) / 255,
216
+ )
tools-gui/tabs/sessions_tab.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Sessions Tab — Recent sessions with search and sort."""
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import gi
8
+
9
+ gi.require_version("Gtk", "3.0")
10
+ from gi.repository import GLib, Gtk
11
+
12
+ sys.path.insert(0, str(Path.home() / "Projekte" / "ClaudeCodePanel"))
13
+ from theme import get_palette
14
+ from monitor import get_recent_sessions
15
+
16
+
17
+ class SessionsTab(Gtk.Box):
18
+ """Sessions list with search filter and sort options."""
19
+
20
+ def __init__(self):
21
+ super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=6)
22
+ self.set_margin_top(8)
23
+ self.set_margin_start(12)
24
+ self.set_margin_end(12)
25
+
26
+ self._sessions: list[dict] = []
27
+ self._filter_text = ""
28
+ self._sort_key = "mtime"
29
+
30
+ self._build_ui()
31
+
32
+ def _build_ui(self):
33
+ # Toolbar: search + sort
34
+ toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
35
+ toolbar.get_style_context().add_class("base-toolbar")
36
+
37
+ search = Gtk.SearchEntry()
38
+ search.set_placeholder_text("Filter by project...")
39
+ search.connect("search-changed", self._on_filter_changed)
40
+ toolbar.pack_start(search, True, True, 0)
41
+
42
+ sort_combo = Gtk.ComboBoxText()
43
+ sort_combo.append("mtime", "Newest first")
44
+ sort_combo.append("project", "By project")
45
+ sort_combo.set_active(0)
46
+ sort_combo.connect("changed", self._on_sort_changed)
47
+ toolbar.pack_start(sort_combo, False, False, 0)
48
+
49
+ self.pack_start(toolbar, False, False, 0)
50
+
51
+ # Scrolled list
52
+ scrolled = Gtk.ScrolledWindow()
53
+ scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
54
+ scrolled.set_vexpand(True)
55
+
56
+ self._listbox = Gtk.ListBox()
57
+ self._listbox.set_selection_mode(Gtk.SelectionMode.NONE)
58
+ scrolled.add(self._listbox)
59
+ self.pack_start(scrolled, True, True, 0)
60
+
61
+ def refresh(self) -> None:
62
+ """Reload session data from monitor.py."""
63
+ self._sessions = get_recent_sessions(n=20)
64
+ self._rebuild_list()
65
+
66
+ def _rebuild_list(self):
67
+ p = get_palette()
68
+
69
+ for child in self._listbox.get_children():
70
+ self._listbox.remove(child)
71
+
72
+ sessions = self._sessions
73
+ if self._filter_text:
74
+ ft = self._filter_text.lower()
75
+ sessions = [s for s in sessions if ft in s.get("project", "").lower()
76
+ or ft in s.get("preview", "").lower()]
77
+
78
+ if self._sort_key == "project":
79
+ sessions = sorted(sessions, key=lambda s: s.get("project", "").lower())
80
+
81
+ if not sessions:
82
+ row = Gtk.ListBoxRow()
83
+ lbl = Gtk.Label()
84
+ lbl.set_markup(f'<span foreground="{p["dim"]}">No sessions found.</span>')
85
+ lbl.set_margin_top(20)
86
+ row.add(lbl)
87
+ self._listbox.add(row)
88
+ self._listbox.show_all()
89
+ return
90
+
91
+ for s in sessions:
92
+ row = Gtk.ListBoxRow()
93
+ row.add(self._build_session_row(s, p))
94
+ self._listbox.add(row)
95
+
96
+ self._listbox.show_all()
97
+
98
+ def _build_session_row(self, s: dict, p: dict) -> Gtk.Box:
99
+ hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
100
+ hbox.get_style_context().add_class("session-row")
101
+ hbox.set_margin_top(2)
102
+ hbox.set_margin_bottom(2)
103
+
104
+ # Left: project + preview
105
+ left = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
106
+
107
+ proj_lbl = Gtk.Label()
108
+ proj_lbl.set_markup(
109
+ f'<span font_weight="bold">{GLib.markup_escape_text(s.get("project", "?"))}</span>'
110
+ )
111
+ proj_lbl.set_xalign(0)
112
+ proj_lbl.get_style_context().add_class("session-project")
113
+ left.pack_start(proj_lbl, False, False, 0)
114
+
115
+ preview = s.get("preview", "")
116
+ if preview:
117
+ prev_lbl = Gtk.Label()
118
+ prev_lbl.set_markup(
119
+ f'<span foreground="{p["subtext1"]}" size="small">'
120
+ f'{GLib.markup_escape_text(preview)}</span>'
121
+ )
122
+ prev_lbl.set_xalign(0)
123
+ prev_lbl.set_line_wrap(True)
124
+ prev_lbl.set_max_width_chars(60)
125
+ left.pack_start(prev_lbl, False, False, 0)
126
+
127
+ hbox.pack_start(left, True, True, 0)
128
+
129
+ # Right: time
130
+ time_lbl = Gtk.Label()
131
+ time_lbl.set_markup(
132
+ f'<span foreground="{p["dim"]}" size="small">'
133
+ f'{GLib.markup_escape_text(s.get("time_str", ""))}</span>'
134
+ )
135
+ time_lbl.set_valign(Gtk.Align.CENTER)
136
+ hbox.pack_end(time_lbl, False, False, 0)
137
+
138
+ return hbox
139
+
140
+ def _on_filter_changed(self, entry):
141
+ self._filter_text = entry.get_text()
142
+ self._rebuild_list()
143
+
144
+ def _on_sort_changed(self, combo):
145
+ self._sort_key = combo.get_active_id() or "mtime"
146
+ self._rebuild_list()
tools-gui/tabs/tools_tab.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Tools Tab — Tool usage breakdown + skill usage."""
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import gi
8
+
9
+ gi.require_version("Gtk", "3.0")
10
+ from gi.repository import GLib, Gtk
11
+
12
+ sys.path.insert(0, str(Path.home() / "Projekte" / "ClaudeCodePanel"))
13
+ from theme import get_palette
14
+ from monitor import get_top_tools, get_skill_usage, get_missed_skills_summary
15
+
16
+
17
+ class ToolsTab(Gtk.Box):
18
+ """Tool + skill usage display."""
19
+
20
+ def __init__(self):
21
+ super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
22
+ self.set_margin_top(12)
23
+ self.set_margin_start(12)
24
+ self.set_margin_end(12)
25
+
26
+ self._tools_box = None
27
+ self._skills_box = None
28
+ self._missed_box = None
29
+
30
+ self._build_ui()
31
+
32
+ def _build_ui(self):
33
+ p = get_palette()
34
+
35
+ # --- Tool usage ---
36
+ tools_lbl = Gtk.Label()
37
+ tools_lbl.set_markup('<b>Tool Usage (Today)</b>')
38
+ tools_lbl.set_halign(Gtk.Align.START)
39
+ tools_lbl.get_style_context().add_class("section-title")
40
+ self.pack_start(tools_lbl, False, False, 0)
41
+
42
+ self._tools_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
43
+ self._tools_box.get_style_context().add_class("base-card")
44
+ self.pack_start(self._tools_box, False, False, 0)
45
+
46
+ # --- Skill usage ---
47
+ skills_lbl = Gtk.Label()
48
+ skills_lbl.set_markup('<b>Skill Usage (Today)</b>')
49
+ skills_lbl.set_halign(Gtk.Align.START)
50
+ skills_lbl.get_style_context().add_class("section-title")
51
+ self.pack_start(skills_lbl, False, False, 0)
52
+
53
+ self._skills_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
54
+ self._skills_box.get_style_context().add_class("base-card")
55
+ self.pack_start(self._skills_box, False, False, 0)
56
+
57
+ # --- Missed skills ---
58
+ missed_lbl = Gtk.Label()
59
+ missed_lbl.set_markup('<b>Missed Skills</b>')
60
+ missed_lbl.set_halign(Gtk.Align.START)
61
+ missed_lbl.get_style_context().add_class("section-title")
62
+ self.pack_start(missed_lbl, False, False, 0)
63
+
64
+ self._missed_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
65
+ self._missed_box.get_style_context().add_class("base-card")
66
+ self.pack_start(self._missed_box, False, False, 0)
67
+
68
+ def refresh(self) -> None:
69
+ """Reload tool/skill data from monitor.py."""
70
+ p = get_palette()
71
+
72
+ # Tool usage
73
+ self._clear_box(self._tools_box)
74
+ tools = get_top_tools(n=15)
75
+ if tools:
76
+ max_count = tools[0][1] if tools else 1
77
+ for name, count in tools:
78
+ row = self._build_bar_row(name, count, max_count, p["accent"], p)
79
+ self._tools_box.pack_start(row, False, False, 0)
80
+ else:
81
+ self._add_empty(self._tools_box, "No tool data yet", p)
82
+ self._tools_box.show_all()
83
+
84
+ # Skill usage
85
+ self._clear_box(self._skills_box)
86
+ skills = get_skill_usage(days=1)
87
+ if skills:
88
+ # Flatten today's skills
89
+ from datetime import datetime
90
+ today = datetime.now().strftime("%Y-%m-%d")
91
+ today_skills = skills.get(today, {})
92
+ if today_skills:
93
+ sorted_skills = sorted(today_skills.items(), key=lambda x: -x[1])
94
+ max_s = sorted_skills[0][1] if sorted_skills else 1
95
+ for name, count in sorted_skills:
96
+ row = self._build_bar_row(name, count, max_s, p["mauve"], p)
97
+ self._skills_box.pack_start(row, False, False, 0)
98
+ else:
99
+ self._add_empty(self._skills_box, "No skills used today", p)
100
+ else:
101
+ self._add_empty(self._skills_box, "No skill data yet", p)
102
+ self._skills_box.show_all()
103
+
104
+ # Missed skills
105
+ self._clear_box(self._missed_box)
106
+ missed = get_missed_skills_summary()
107
+ if missed:
108
+ for name, count in missed[:8]:
109
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
110
+ name_lbl = Gtk.Label()
111
+ name_lbl.set_markup(f'<tt>{GLib.markup_escape_text(name)}</tt>')
112
+ name_lbl.set_xalign(0)
113
+ name_lbl.set_width_chars(20)
114
+ row.pack_start(name_lbl, False, False, 0)
115
+ count_lbl = Gtk.Label()
116
+ count_lbl.set_markup(
117
+ f'<span foreground="{p["red"]}" size="small">{count} missed</span>'
118
+ )
119
+ row.pack_end(count_lbl, False, False, 0)
120
+ self._missed_box.pack_start(row, False, False, 0)
121
+ else:
122
+ self._add_empty(self._missed_box, "No missed skills", p)
123
+ self._missed_box.show_all()
124
+
125
+ def _build_bar_row(self, name: str, count: int, max_count: int, bar_color: str, p: dict) -> Gtk.Box:
126
+ """Build a row with name, count, and a proportional bar."""
127
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
128
+
129
+ name_lbl = Gtk.Label()
130
+ name_lbl.set_markup(f'<tt>{GLib.markup_escape_text(name)}</tt>')
131
+ name_lbl.set_xalign(0)
132
+ name_lbl.set_width_chars(20)
133
+ row.pack_start(name_lbl, False, False, 0)
134
+
135
+ # Visual bar
136
+ bar = Gtk.DrawingArea()
137
+ frac = count / max_count if max_count > 0 else 0
138
+ bar.set_size_request(max(4, int(frac * 150)), 12)
139
+ bar.connect("draw", self._draw_mini_bar, bar_color)
140
+ bar.set_valign(Gtk.Align.CENTER)
141
+ row.pack_start(bar, False, False, 0)
142
+
143
+ count_lbl = Gtk.Label()
144
+ count_lbl.set_markup(
145
+ f'<span foreground="{p["overlay"]}" font_family="monospace" size="small">{count}</span>'
146
+ )
147
+ row.pack_start(count_lbl, False, False, 0)
148
+
149
+ return row
150
+
151
+ @staticmethod
152
+ def _draw_mini_bar(widget, cr, color_hex):
153
+ width = widget.get_allocated_width()
154
+ height = widget.get_allocated_height()
155
+ r = int(color_hex[1:3], 16) / 255
156
+ g = int(color_hex[3:5], 16) / 255
157
+ b = int(color_hex[5:7], 16) / 255
158
+ cr.set_source_rgba(r, g, b, 0.6)
159
+ # Rounded rectangle
160
+ radius = 3
161
+ cr.arc(radius, radius, radius, 3.14159, 1.5 * 3.14159)
162
+ cr.arc(width - radius, radius, radius, 1.5 * 3.14159, 0)
163
+ cr.arc(width - radius, height - radius, radius, 0, 0.5 * 3.14159)
164
+ cr.arc(radius, height - radius, radius, 0.5 * 3.14159, 3.14159)
165
+ cr.close_path()
166
+ cr.fill()
167
+ return False
168
+
169
+ @staticmethod
170
+ def _clear_box(box):
171
+ for child in box.get_children():
172
+ box.remove(child)
173
+
174
+ @staticmethod
175
+ def _add_empty(box, text, p):
176
+ lbl = Gtk.Label()
177
+ lbl.set_markup(f'<span foreground="{p["dim"]}">{text}</span>')
178
+ box.pack_start(lbl, False, False, 0)
utils.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared utilities for Claude Code Control Panel."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from gi.repository import GLib
7
+
8
+ # Cached constants — avoid per-call computation
9
+ HOME = str(Path.home())
10
+ CLK_TCK: int = os.sysconf("SC_CLK_TCK")
11
+
12
+
13
+ def idle_once(fn, *args):
14
+ """Schedule fn(*args) as a one-shot GLib.idle_add (won't re-schedule)."""
15
+ def _wrapper():
16
+ fn(*args)
17
+ return False
18
+ GLib.idle_add(_wrapper)
19
+
20
+
21
+ def short_name_from_path(cwd: str) -> str:
22
+ """Extract short project name from a path.
23
+
24
+ Examples:
25
+ "/home/smlflg" → "Home"
26
+ "/home/smlflg/Projekte/ClaudeCodePanel" → "ClaudeCodePanel"
27
+ "/home/smlflg/Erdhügel" → "Erdhügel"
28
+ "/media/smlflg/writable" → "writable"
29
+ """
30
+ if cwd == HOME or not cwd:
31
+ return "Home"
32
+ last = cwd.rstrip("/").rsplit("/", 1)[-1]
33
+ return last if last else "Home"