File size: 13,186 Bytes
bbc65cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
"""Event tab widget for Claude Code Control Panel.

Live hook event viewer with filtering and auto-scroll.
Reads from ~/.claude/events/YYYY-MM-DD.jsonl (day-file tracking).
Auto-refreshes via external timer calling refresh_events().
"""

import json
from datetime import date, datetime
from pathlib import Path

import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib, Pango

from theme import get_palette
from utils import idle_once

EVENTS_DIR = Path.home() / ".claude" / "events"

_MAX_ROWS = 500

# Module-level widget references
_list_box = None
_filter_combo = None
_search_entry = None
_auto_scroll_btn = None
_scrolled_window = None
_stats_label = None

# State
_rows = []           # list of parsed event dicts (most recent last)
_file_offset = 0     # byte offset into current day file
_current_day = None  # date string "YYYY-MM-DD" of last read

# All known hook event types (for filter combo population)
_KNOWN_TYPES = [
    "PreToolUse",
    "PostToolUse",
    "PostToolUseFailure",
    "UserPromptSubmit",
    "SubagentStart",
    "SubagentStop",
    "SessionStart",
    "SessionEnd",
    "PreCompact",
    "Notification",
    "PermissionRequest",
    "Stop",
]


def _type_color(event_type: str) -> str:
    """Return palette hex color for a given event type badge."""
    p = get_palette()
    mapping = {
        "PreToolUse": p["accent"],
        "PostToolUse": p["green"],
        "PostToolUseFailure": p["red"],
        "UserPromptSubmit": p["mauve"],
        "SubagentStart": p["peach"],
        "SubagentStop": p["yellow"],
        "SessionStart": p["teal"],
        "SessionEnd": p["teal"],
        "PreCompact": p["peach"],
        "Notification": p["lavender"],
        "PermissionRequest": p["red"],
        "Stop": p["dim"],
    }
    return mapping.get(event_type, p["overlay"])


def build_events_tab() -> Gtk.ScrolledWindow:
    """Build the Events tab widget."""
    global _list_box, _filter_combo, _search_entry, _auto_scroll_btn
    global _scrolled_window, _stats_label

    outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)

    # --- Toolbar ---
    toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
    toolbar.get_style_context().add_class("log-toolbar")
    toolbar.set_margin_top(8)
    toolbar.set_margin_bottom(6)
    toolbar.set_margin_start(10)
    toolbar.set_margin_end(10)

    filter_label = Gtk.Label(label="Type:")
    toolbar.pack_start(filter_label, False, False, 0)

    _filter_combo = Gtk.ComboBoxText()
    _filter_combo.append_text("All")
    for t in _KNOWN_TYPES:
        _filter_combo.append_text(t)
    _filter_combo.set_active(0)
    _filter_combo.connect("changed", _on_filter_changed)
    toolbar.pack_start(_filter_combo, False, False, 0)

    session_label = Gtk.Label(label="Session:")
    toolbar.pack_start(session_label, False, False, 0)

    _search_entry = Gtk.SearchEntry()
    _search_entry.set_placeholder_text("session ID...")
    _search_entry.set_size_request(140, -1)
    _search_entry.connect("search-changed", _on_filter_changed)
    toolbar.pack_start(_search_entry, False, False, 0)

    # Spacer
    spacer = Gtk.Label(label="")
    spacer.set_hexpand(True)
    toolbar.pack_start(spacer, True, True, 0)

    _stats_label = Gtk.Label(label="")
    _stats_label.set_xalign(1.0)
    _stats_label.get_style_context().add_class("log-stats")
    toolbar.pack_start(_stats_label, False, False, 0)

    _auto_scroll_btn = Gtk.ToggleButton(label="Auto-scroll")
    _auto_scroll_btn.set_active(True)
    toolbar.pack_start(_auto_scroll_btn, False, False, 0)

    clear_btn = Gtk.Button(label="Clear")
    clear_btn.connect("clicked", _on_clear_clicked)
    toolbar.pack_start(clear_btn, False, False, 0)

    outer.pack_start(toolbar, False, False, 0)
    outer.pack_start(
        Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0
    )

    # --- Scrolled list ---
    _scrolled_window = Gtk.ScrolledWindow()
    _scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
    _scrolled_window.set_vexpand(True)

    _list_box = Gtk.ListBox()
    _list_box.set_selection_mode(Gtk.SelectionMode.NONE)
    _scrolled_window.add(_list_box)

    outer.pack_start(_scrolled_window, True, True, 0)

    # Wrap in a ScrolledWindow for the tab API contract
    wrapper = Gtk.ScrolledWindow()
    wrapper.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER)
    wrapper.add(outer)

    idle_once(refresh_events)
    return wrapper


def refresh_events() -> bool:
    """Called by panel.py timer (2s). Reads new JSONL lines. Returns True to keep timer."""
    global _file_offset, _current_day, _rows

    if _list_box is None:
        return True

    try:
        today_str = date.today().isoformat()

        # Day rollover — reset offset
        if _current_day != today_str:
            _file_offset = 0
            _current_day = today_str

        today_file = EVENTS_DIR / f"{today_str}.jsonl"
        if not today_file.exists():
            _update_stats()
            return True

        new_events = []
        try:
            with today_file.open("rb") as f:
                f.seek(_file_offset)
                while True:
                    line = f.readline()
                    if not line:
                        break
                    _file_offset = f.tell()
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        data = json.loads(line)
                        new_events.append(data)
                    except json.JSONDecodeError:
                        continue
        except OSError:
            return True

        if not new_events:
            return True

        for event in new_events:
            _rows.append(event)

        # Trim to max rows
        if len(_rows) > _MAX_ROWS:
            excess = len(_rows) - _MAX_ROWS
            _rows = _rows[excess:]
            # Remove oldest rows from list_box
            children = _list_box.get_children()
            for i in range(min(excess, len(children))):
                _list_box.remove(children[i])

        # Add new rows to list_box
        active_type = _current_filter_type()
        session_filter = _current_session_filter()
        for event in new_events:
            if _event_matches(event, active_type, session_filter):
                row = _build_row(event)
                _list_box.add(row)

        _list_box.show_all()
        _update_stats()

        # Auto-scroll
        if _auto_scroll_btn is not None and _auto_scroll_btn.get_active():
            _scroll_to_bottom()

    except Exception:
        pass
    return True


# ---------------------------------------------------------------------------
# Row building
# ---------------------------------------------------------------------------

def _build_row(event: dict) -> Gtk.ListBoxRow:
    """Create a single ListBoxRow from an event dict."""
    row = Gtk.ListBoxRow()
    row.set_activatable(False)

    hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
    hbox.set_margin_top(3)
    hbox.set_margin_bottom(3)
    hbox.set_margin_start(8)
    hbox.set_margin_end(8)

    # Time label
    time_str = _parse_time(event)
    time_lbl = Gtk.Label(label=time_str)
    time_lbl.set_size_request(65, -1)
    time_lbl.set_xalign(0.0)
    time_lbl.get_style_context().add_class("log-time")
    hbox.pack_start(time_lbl, False, False, 0)

    # Type badge (colored via Pango markup)
    event_type = event.get("hook_event_name", event.get("type", "Unknown"))
    color = _type_color(event_type)
    badge = Gtk.Label()
    badge.set_markup(f'<span foreground="{color}" font_weight="bold">{GLib.markup_escape_text(event_type)}</span>')
    badge.set_size_request(160, -1)
    badge.set_xalign(0.0)
    badge.set_ellipsize(Pango.EllipsizeMode.END)
    hbox.pack_start(badge, False, False, 0)

    # Session ID (first 8 chars)
    session_id = event.get("session_id", "")
    session_short = session_id[:8] if session_id else "—"
    session_lbl = Gtk.Label(label=session_short)
    session_lbl.set_size_request(72, -1)
    session_lbl.set_xalign(0.0)
    session_lbl.get_style_context().add_class("log-time")
    hbox.pack_start(session_lbl, False, False, 0)

    # Detail text
    detail = _event_detail(event)
    detail_lbl = Gtk.Label(label=detail)
    detail_lbl.set_hexpand(True)
    detail_lbl.set_xalign(0.0)
    detail_lbl.set_ellipsize(Pango.EllipsizeMode.END)
    detail_lbl.get_style_context().add_class("log-detail")
    hbox.pack_start(detail_lbl, True, True, 0)

    row.add(hbox)

    # Tooltip: full event JSON summary
    tooltip_parts = []
    if session_id:
        tooltip_parts.append(f"session: {session_id}")
    tool = event.get("tool_name") or event.get("tool")
    if tool:
        tooltip_parts.append(f"tool: {tool}")
    if tooltip_parts:
        row.set_tooltip_text(" | ".join(tooltip_parts))

    return row


def _parse_time(event: dict) -> str:
    """Extract HH:MM:SS from event timestamp."""
    ts = event.get("timestamp", event.get("time", ""))
    if not ts:
        return ""
    try:
        dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
        return dt.astimezone().strftime("%H:%M:%S")
    except (ValueError, TypeError):
        return ts[:8] if len(ts) >= 8 else ts


def _event_detail(event: dict) -> str:
    """Extract a human-readable detail string from an event."""
    # Tool name takes priority
    tool = event.get("tool_name") or event.get("tool")
    if tool:
        path = event.get("tool_input", {}).get("path", "") if isinstance(event.get("tool_input"), dict) else ""
        if path:
            home = str(Path.home())
            if path.startswith(home + "/"):
                path = "~/" + path[len(home) + 1:]
            return f"{tool}  {path}"
        return tool

    # Fallback: pick meaningful fields
    for key in ("message", "text", "prompt", "summary", "reason", "content"):
        val = event.get(key, "")
        if val and isinstance(val, str):
            return val[:120]

    # Last resort: show non-meta keys
    skip = {"timestamp", "time", "session_id", "hook_event_name", "type"}
    parts = []
    for k, v in event.items():
        if k not in skip and not k.startswith("_"):
            parts.append(f"{k}={v!r}")
        if len(parts) >= 4:
            break
    return "  ".join(parts)[:120]


# ---------------------------------------------------------------------------
# Filtering
# ---------------------------------------------------------------------------

def _current_filter_type() -> str | None:
    if _filter_combo is None:
        return None
    active = _filter_combo.get_active_text() or "All"
    return None if active == "All" else active


def _current_session_filter() -> str:
    if _search_entry is None:
        return ""
    return (_search_entry.get_text() or "").strip().lower()


def _event_matches(event: dict, filter_type: str | None, session_filter: str) -> bool:
    if filter_type is not None:
        etype = event.get("hook_event_name", event.get("type", ""))
        if etype != filter_type:
            return False
    if session_filter:
        sid = (event.get("session_id", "") or "").lower()
        if session_filter not in sid:
            return False
    return True


def _on_filter_changed(_widget):
    """Rebuild visible rows when filter combo or search entry changes."""
    _rebuild_list()


def _rebuild_list():
    """Clear and re-populate list_box from _rows with current filters."""
    if _list_box is None:
        return
    for child in _list_box.get_children():
        _list_box.remove(child)

    active_type = _current_filter_type()
    session_filter = _current_session_filter()

    for event in _rows:
        if _event_matches(event, active_type, session_filter):
            row = _build_row(event)
            _list_box.add(row)

    _list_box.show_all()
    _update_stats()

    if _auto_scroll_btn is not None and _auto_scroll_btn.get_active():
        _scroll_to_bottom()


# ---------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------

def _on_clear_clicked(_btn):
    global _rows, _file_offset
    _rows = []
    _file_offset = 0  # re-read from start on next refresh (soft clear)
    if _list_box is not None:
        for child in _list_box.get_children():
            _list_box.remove(child)
    _update_stats()


def _scroll_to_bottom():
    if _scrolled_window is None:
        return
    adj = _scrolled_window.get_vadjustment()
    if adj is not None:
        GLib.idle_add(_do_scroll, adj)


def _do_scroll(adj) -> bool:
    adj.set_value(adj.get_upper() - adj.get_page_size())
    return False


# ---------------------------------------------------------------------------
# Stats
# ---------------------------------------------------------------------------

def _update_stats():
    if _stats_label is None:
        return
    total = len(_rows)
    now = datetime.now().strftime("%H:%M")
    _stats_label.set_text(f"{total} events | {now}")