Initial public upload from Projekte/ClaudeCodePanel
Browse files- .codex/config.toml +31 -0
- .gitignore +18 -0
- FOR_SMLFLG.md +245 -0
- PLAN_2026-03-13_quickwins.md +49 -0
- README.md +95 -0
- brightness_tab.py +807 -0
- claude-panel-icon.png +0 -0
- claude-panel.desktop +10 -0
- claude-panel.service +14 -0
- config_io.py +168 -0
- event_tab.py +426 -0
- log_viewer.py +435 -0
- monitor.py +802 -0
- panel.py +0 -0
- process_manager.py +611 -0
- project_dashboard_tab.py +254 -0
- requirements.txt +1 -0
- session_browser.py +1039 -0
- shortcut_counter_tab.py +452 -0
- swarm_tab.py +974 -0
- swarm_visual.py +708 -0
- test_monitor.py +67 -0
- test_shortcut_counter.py +318 -0
- theme.py +620 -0
- tools-gui/main.py +83 -0
- tools-gui/tabs/__init__.py +0 -0
- tools-gui/tabs/costs_tab.py +216 -0
- tools-gui/tabs/sessions_tab.py +146 -0
- tools-gui/tabs/tools_tab.py +178 -0
- utils.py +33 -0
.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": "✓", "in_progress": "●"}.get(st, "○")
|
| 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} → {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 — {_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 · {len(tasks)} Tasks · {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'?'✓':t.status==='in_progress'?'●':'○';
|
| 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||'')+' → '+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"
|