@mauribadnights/clooks 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js ADDED
@@ -0,0 +1,730 @@
1
+ "use strict";
2
+ // clooks interactive TUI dashboard for `clooks stats -i`
3
+ // Modern terminal UI using raw ANSI escape codes + Node readline
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.launchDashboard = launchDashboard;
6
+ const fs_1 = require("fs");
7
+ const constants_js_1 = require("./constants.js");
8
+ // --- ANSI helpers ---
9
+ const ESC = '\x1b';
10
+ const CSI = `${ESC}[`;
11
+ const ansi = {
12
+ clearScreen: `${CSI}2J`,
13
+ cursorHome: `${CSI}H`,
14
+ hideCursor: `${CSI}?25l`,
15
+ showCursor: `${CSI}?25h`,
16
+ bold: `${CSI}1m`,
17
+ dim: `${CSI}2m`,
18
+ italic: `${CSI}3m`,
19
+ underline: `${CSI}4m`,
20
+ reverse: `${CSI}7m`,
21
+ reset: `${CSI}0m`,
22
+ red: `${CSI}31m`,
23
+ green: `${CSI}32m`,
24
+ yellow: `${CSI}33m`,
25
+ blue: `${CSI}34m`,
26
+ magenta: `${CSI}35m`,
27
+ cyan: `${CSI}36m`,
28
+ white: `${CSI}37m`,
29
+ dimWhite: `${CSI}2;37m`,
30
+ bgBlue: `${CSI}44m`,
31
+ eraseLine: `${CSI}2K`,
32
+ moveTo(row, col) {
33
+ return `${CSI}${row};${col}H`;
34
+ },
35
+ };
36
+ function write(s) {
37
+ process.stdout.write(s);
38
+ }
39
+ function getTermSize() {
40
+ return {
41
+ rows: process.stdout.rows || 24,
42
+ cols: process.stdout.columns || 80,
43
+ };
44
+ }
45
+ // --- Data loading ---
46
+ function loadAllMetricEntries() {
47
+ if (!(0, fs_1.existsSync)(constants_js_1.METRICS_FILE))
48
+ return [];
49
+ try {
50
+ const raw = (0, fs_1.readFileSync)(constants_js_1.METRICS_FILE, 'utf-8');
51
+ const lines = raw.trim().split('\n').filter(Boolean);
52
+ return lines.map((line) => JSON.parse(line));
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ function aggregateByEvent(entries) {
59
+ const byEvent = new Map();
60
+ for (const e of entries) {
61
+ const arr = byEvent.get(e.event) ?? [];
62
+ arr.push(e);
63
+ byEvent.set(e.event, arr);
64
+ }
65
+ const result = [];
66
+ for (const [event, evEntries] of byEvent) {
67
+ const durations = evEntries.map((e) => e.duration_ms);
68
+ const byHandler = new Map();
69
+ for (const e of evEntries) {
70
+ const arr = byHandler.get(e.handler) ?? [];
71
+ arr.push(e);
72
+ byHandler.set(e.handler, arr);
73
+ }
74
+ const handlers = [];
75
+ for (const [handler, hEntries] of byHandler) {
76
+ const hDurations = hEntries.map((e) => e.duration_ms);
77
+ handlers.push({
78
+ handler,
79
+ event,
80
+ fires: hEntries.length,
81
+ errors: hEntries.filter((e) => !e.ok).length,
82
+ avgMs: hDurations.reduce((a, b) => a + b, 0) / hDurations.length,
83
+ maxMs: Math.max(...hDurations),
84
+ });
85
+ }
86
+ handlers.sort((a, b) => b.avgMs - a.avgMs);
87
+ result.push({
88
+ event,
89
+ fires: evEntries.length,
90
+ errors: evEntries.filter((e) => !e.ok).length,
91
+ avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
92
+ maxMs: Math.max(...durations),
93
+ handlers,
94
+ });
95
+ }
96
+ return result.sort((a, b) => b.fires - a.fires);
97
+ }
98
+ function aggregateAllHandlers(entries) {
99
+ const byHandler = new Map();
100
+ for (const e of entries) {
101
+ const arr = byHandler.get(e.handler) ?? [];
102
+ arr.push(e);
103
+ byHandler.set(e.handler, arr);
104
+ }
105
+ const result = [];
106
+ for (const [handler, hEntries] of byHandler) {
107
+ const durations = hEntries.map((e) => e.duration_ms);
108
+ result.push({
109
+ handler,
110
+ event: hEntries[0].event,
111
+ fires: hEntries.length,
112
+ errors: hEntries.filter((e) => !e.ok).length,
113
+ avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
114
+ maxMs: Math.max(...durations),
115
+ });
116
+ }
117
+ return result.sort((a, b) => b.avgMs - a.avgMs);
118
+ }
119
+ function aggregateByAgent(entries) {
120
+ // Group by actual agent_type field from metrics
121
+ const byAgent = new Map();
122
+ let hasAgentData = false;
123
+ for (const e of entries) {
124
+ const agent = e.agent_type ?? '';
125
+ if (agent)
126
+ hasAgentData = true;
127
+ const arr = byAgent.get(agent) ?? [];
128
+ arr.push(e);
129
+ byAgent.set(agent, arr);
130
+ }
131
+ // If no entries have agent_type, return empty — the view will show a message
132
+ if (!hasAgentData)
133
+ return [];
134
+ const result = [];
135
+ for (const [agent, aEntries] of byAgent) {
136
+ if (!agent)
137
+ continue; // skip entries with no agent
138
+ const durations = aEntries.map((e) => e.duration_ms);
139
+ const handlers = aggregateHandlersFrom(aEntries);
140
+ result.push({
141
+ agent,
142
+ fires: aEntries.length,
143
+ errors: aEntries.filter((e) => !e.ok).length,
144
+ avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
145
+ handlers,
146
+ });
147
+ }
148
+ return result.sort((a, b) => b.fires - a.fires);
149
+ }
150
+ function aggregateByProject(entries) {
151
+ const bySid = new Map();
152
+ for (const e of entries) {
153
+ const key = e.session_id ?? 'unknown';
154
+ const arr = bySid.get(key) ?? [];
155
+ arr.push(e);
156
+ bySid.set(key, arr);
157
+ }
158
+ const result = [];
159
+ for (const [sid, pEntries] of bySid) {
160
+ const durations = pEntries.map((e) => e.duration_ms);
161
+ const handlers = aggregateHandlersFrom(pEntries);
162
+ const label = sid.length > 16 ? sid.slice(0, 8) + '..' + sid.slice(-8) : sid;
163
+ result.push({
164
+ project: label,
165
+ fires: pEntries.length,
166
+ errors: pEntries.filter((e) => !e.ok).length,
167
+ avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
168
+ handlers,
169
+ });
170
+ }
171
+ return result.sort((a, b) => b.fires - a.fires);
172
+ }
173
+ function aggregateHandlersFrom(entries) {
174
+ const byHandler = new Map();
175
+ for (const e of entries) {
176
+ const arr = byHandler.get(e.handler) ?? [];
177
+ arr.push(e);
178
+ byHandler.set(e.handler, arr);
179
+ }
180
+ const result = [];
181
+ for (const [handler, hEntries] of byHandler) {
182
+ const durations = hEntries.map((e) => e.duration_ms);
183
+ result.push({
184
+ handler,
185
+ event: hEntries[0].event,
186
+ fires: hEntries.length,
187
+ errors: hEntries.filter((e) => !e.ok).length,
188
+ avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
189
+ maxMs: Math.max(...durations),
190
+ });
191
+ }
192
+ return result.sort((a, b) => b.avgMs - a.avgMs);
193
+ }
194
+ // --- Fuzzy search ---
195
+ function fuzzyMatch(query, target) {
196
+ const q = query.toLowerCase();
197
+ const t = target.toLowerCase();
198
+ let qi = 0;
199
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
200
+ if (t[ti] === q[qi])
201
+ qi++;
202
+ }
203
+ return qi === q.length;
204
+ }
205
+ // --- Color helpers ---
206
+ function colorMs(ms, text) {
207
+ if (ms < 5)
208
+ return `${ansi.green}${text}${ansi.reset}`;
209
+ if (ms <= 50)
210
+ return `${ansi.yellow}${text}${ansi.reset}`;
211
+ return `${ansi.red}${text}${ansi.reset}`;
212
+ }
213
+ function colorErrors(errors, text) {
214
+ if (errors === 0)
215
+ return `${ansi.green}${text}${ansi.reset}`;
216
+ return `${ansi.red}${text}${ansi.reset}`;
217
+ }
218
+ // --- Row formatting ---
219
+ // Strip ANSI codes for length calculation
220
+ function stripAnsi(s) {
221
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
222
+ }
223
+ function pad(text, width, right = false) {
224
+ const visible = stripAnsi(text);
225
+ const diff = width - visible.length;
226
+ if (diff <= 0)
227
+ return text;
228
+ const spaces = ' '.repeat(diff);
229
+ return right ? spaces + text : text + spaces;
230
+ }
231
+ function rpad(text, width) {
232
+ return pad(text, width, true);
233
+ }
234
+ const VIEW_ORDER = ['events', 'handlers', 'agents', 'projects'];
235
+ const VIEW_LABELS = {
236
+ events: 'Events',
237
+ handlers: 'Handlers',
238
+ agents: 'Agents',
239
+ projects: 'Projects',
240
+ };
241
+ // --- Main TUI ---
242
+ function launchDashboard() {
243
+ const entries = loadAllMetricEntries();
244
+ const state = {
245
+ currentView: 'events',
246
+ selectedIndex: 0,
247
+ scrollOffset: 0,
248
+ drillDown: null,
249
+ searchMode: false,
250
+ searchQuery: '',
251
+ entries,
252
+ eventAggs: aggregateByEvent(entries),
253
+ handlerAggs: aggregateAllHandlers(entries),
254
+ agentAggs: aggregateByAgent(entries),
255
+ projectAggs: aggregateByProject(entries),
256
+ };
257
+ // Setup terminal
258
+ process.stdin.setRawMode(true);
259
+ process.stdin.resume();
260
+ process.stdin.setEncoding('utf-8');
261
+ write(ansi.hideCursor);
262
+ write(ansi.clearScreen);
263
+ // Cleanup on exit
264
+ function cleanup() {
265
+ write(ansi.showCursor);
266
+ write(ansi.clearScreen);
267
+ write(ansi.cursorHome);
268
+ process.stdin.setRawMode(false);
269
+ process.exit(0);
270
+ }
271
+ process.on('SIGINT', cleanup);
272
+ process.on('SIGTERM', cleanup);
273
+ // Handle resize
274
+ process.stdout.on('resize', () => render(state));
275
+ // Input handling
276
+ process.stdin.on('data', (key) => {
277
+ if (state.searchMode) {
278
+ handleSearchInput(state, key, cleanup);
279
+ }
280
+ else {
281
+ handleNormalInput(state, key, cleanup);
282
+ }
283
+ render(state);
284
+ });
285
+ // Initial render
286
+ render(state);
287
+ }
288
+ function handleNormalInput(state, key, cleanup) {
289
+ // q or Ctrl+C: quit
290
+ if (key === 'q' || key === '\x03') {
291
+ cleanup();
292
+ return;
293
+ }
294
+ // Tab: next view
295
+ if (key === '\t') {
296
+ switchView(state, 1);
297
+ return;
298
+ }
299
+ // Shift+Tab (ESC [ Z)
300
+ if (key === `${ESC}[Z`) {
301
+ switchView(state, -1);
302
+ return;
303
+ }
304
+ // Arrow up / k
305
+ if (key === `${ESC}[A` || key === 'k') {
306
+ if (state.selectedIndex > 0)
307
+ state.selectedIndex--;
308
+ adjustScroll(state);
309
+ return;
310
+ }
311
+ // Arrow down / j
312
+ if (key === `${ESC}[B` || key === 'j') {
313
+ const maxIdx = getRowCount(state) - 1;
314
+ if (state.selectedIndex < maxIdx)
315
+ state.selectedIndex++;
316
+ adjustScroll(state);
317
+ return;
318
+ }
319
+ // Enter: drill in
320
+ if (key === '\r') {
321
+ drillIn(state);
322
+ return;
323
+ }
324
+ // Escape or Backspace: go back
325
+ if (key === ESC || key === '\x7f') {
326
+ goBack(state);
327
+ return;
328
+ }
329
+ // /: search mode
330
+ if (key === '/') {
331
+ state.searchMode = true;
332
+ state.searchQuery = '';
333
+ return;
334
+ }
335
+ // 1-4: direct tab selection
336
+ if (key >= '1' && key <= '4') {
337
+ const idx = parseInt(key) - 1;
338
+ state.drillDown = null;
339
+ state.currentView = VIEW_ORDER[idx];
340
+ state.selectedIndex = 0;
341
+ state.scrollOffset = 0;
342
+ return;
343
+ }
344
+ }
345
+ function handleSearchInput(state, key, _cleanup) {
346
+ // Escape: cancel search
347
+ if (key === ESC || key === '\x03') {
348
+ state.searchMode = false;
349
+ state.searchQuery = '';
350
+ return;
351
+ }
352
+ // Enter: accept search and stay on filtered view
353
+ if (key === '\r') {
354
+ state.searchMode = false;
355
+ return;
356
+ }
357
+ // Backspace
358
+ if (key === '\x7f') {
359
+ state.searchQuery = state.searchQuery.slice(0, -1);
360
+ state.selectedIndex = 0;
361
+ state.scrollOffset = 0;
362
+ return;
363
+ }
364
+ // Printable character
365
+ if (key.length === 1 && key.charCodeAt(0) >= 32) {
366
+ state.searchQuery += key;
367
+ state.selectedIndex = 0;
368
+ state.scrollOffset = 0;
369
+ }
370
+ }
371
+ function switchView(state, direction) {
372
+ state.drillDown = null;
373
+ state.searchQuery = '';
374
+ const idx = VIEW_ORDER.indexOf(state.currentView);
375
+ const next = (idx + direction + VIEW_ORDER.length) % VIEW_ORDER.length;
376
+ state.currentView = VIEW_ORDER[next];
377
+ state.selectedIndex = 0;
378
+ state.scrollOffset = 0;
379
+ }
380
+ function drillIn(state) {
381
+ if (state.drillDown)
382
+ return; // already drilled in
383
+ if (state.currentView === 'events') {
384
+ const ev = state.eventAggs[state.selectedIndex];
385
+ if (ev) {
386
+ state.drillDown = ev;
387
+ state.selectedIndex = 0;
388
+ state.scrollOffset = 0;
389
+ }
390
+ }
391
+ else if (state.currentView === 'agents') {
392
+ const ag = state.agentAggs[state.selectedIndex];
393
+ if (ag) {
394
+ state.drillDown = ag;
395
+ state.selectedIndex = 0;
396
+ state.scrollOffset = 0;
397
+ }
398
+ }
399
+ else if (state.currentView === 'projects') {
400
+ const pr = state.projectAggs[state.selectedIndex];
401
+ if (pr) {
402
+ state.drillDown = pr;
403
+ state.selectedIndex = 0;
404
+ state.scrollOffset = 0;
405
+ }
406
+ }
407
+ }
408
+ function goBack(state) {
409
+ if (state.searchQuery) {
410
+ state.searchQuery = '';
411
+ state.selectedIndex = 0;
412
+ state.scrollOffset = 0;
413
+ return;
414
+ }
415
+ if (state.drillDown) {
416
+ state.drillDown = null;
417
+ state.selectedIndex = 0;
418
+ state.scrollOffset = 0;
419
+ }
420
+ }
421
+ function getRowCount(state) {
422
+ const rows = getRows(state);
423
+ return rows.length;
424
+ }
425
+ function adjustScroll(state) {
426
+ const { rows } = getTermSize();
427
+ const contentHeight = rows - 6; // header(2) + separator(1) + footer separator(1) + footer(2)
428
+ if (state.selectedIndex < state.scrollOffset) {
429
+ state.scrollOffset = state.selectedIndex;
430
+ }
431
+ else if (state.selectedIndex >= state.scrollOffset + contentHeight) {
432
+ state.scrollOffset = state.selectedIndex - contentHeight + 1;
433
+ }
434
+ }
435
+ function getRows(state) {
436
+ const query = state.searchQuery.toLowerCase();
437
+ if (state.entries.length === 0) {
438
+ return [
439
+ { label: 'No metrics recorded yet.' },
440
+ { label: '' },
441
+ { label: 'Start the clooks daemon and run some Claude Code sessions' },
442
+ { label: 'to see hook execution statistics here.' },
443
+ ];
444
+ }
445
+ if (state.currentView === 'events') {
446
+ return getEventsRows(state, query);
447
+ }
448
+ else if (state.currentView === 'handlers') {
449
+ return getHandlersRows(state, query);
450
+ }
451
+ else if (state.currentView === 'agents') {
452
+ return getAgentsRows(state, query);
453
+ }
454
+ else {
455
+ return getProjectsRows(state, query);
456
+ }
457
+ }
458
+ function makeEventRow(ev) {
459
+ const name = pad(ev.event, 22);
460
+ const fires = rpad(String(ev.fires), 8);
461
+ const errs = colorErrors(ev.errors, rpad(String(ev.errors), 8));
462
+ const avg = colorMs(ev.avgMs, rpad(ev.avgMs.toFixed(1), 9));
463
+ const max = colorMs(ev.maxMs, rpad(ev.maxMs.toFixed(1), 9));
464
+ return `${name}${fires}${errs}${avg}${max}`;
465
+ }
466
+ function makeHandlerRow(h) {
467
+ const name = pad(h.handler, 28);
468
+ const ev = pad(h.event, 18);
469
+ const fires = rpad(String(h.fires), 8);
470
+ const errs = colorErrors(h.errors, rpad(String(h.errors), 8));
471
+ const avg = colorMs(h.avgMs, rpad(h.avgMs.toFixed(1), 9));
472
+ const max = colorMs(h.maxMs, rpad(h.maxMs.toFixed(1), 9));
473
+ return `${name}${ev}${fires}${errs}${avg}${max}`;
474
+ }
475
+ function eventHeader() {
476
+ const name = pad('Event', 22);
477
+ const fires = rpad('Fires', 8);
478
+ const errs = rpad('Errors', 8);
479
+ const avg = rpad('Avg ms', 9);
480
+ const max = rpad('Max ms', 9);
481
+ return { label: `${name}${fires}${errs}${avg}${max}`, isHeader: true };
482
+ }
483
+ function handlerHeader() {
484
+ const name = pad('Handler', 28);
485
+ const ev = pad('Event', 18);
486
+ const fires = rpad('Fires', 8);
487
+ const errs = rpad('Errors', 8);
488
+ const avg = rpad('Avg ms', 9);
489
+ const max = rpad('Max ms', 9);
490
+ return { label: `${name}${ev}${fires}${errs}${avg}${max}`, isHeader: true };
491
+ }
492
+ function getEventsRows(state, query) {
493
+ if (state.drillDown && 'event' in state.drillDown && 'handlers' in state.drillDown) {
494
+ const ev = state.drillDown;
495
+ const rows = [handlerHeader()];
496
+ for (const h of ev.handlers) {
497
+ if (query && !fuzzyMatch(query, h.handler))
498
+ continue;
499
+ rows.push({ label: makeHandlerRow(h) });
500
+ }
501
+ return rows;
502
+ }
503
+ const rows = [eventHeader()];
504
+ for (const ev of state.eventAggs) {
505
+ if (query && !fuzzyMatch(query, ev.event))
506
+ continue;
507
+ rows.push({ label: makeEventRow(ev) });
508
+ }
509
+ return rows;
510
+ }
511
+ function getHandlersRows(state, query) {
512
+ const rows = [handlerHeader()];
513
+ for (const h of state.handlerAggs) {
514
+ if (query && !fuzzyMatch(query, h.handler) && !fuzzyMatch(query, h.event))
515
+ continue;
516
+ rows.push({ label: makeHandlerRow(h) });
517
+ }
518
+ return rows;
519
+ }
520
+ function getAgentsRows(state, query) {
521
+ if (state.agentAggs.length === 0) {
522
+ return [
523
+ { label: 'No agent data available.' },
524
+ { label: '' },
525
+ { label: 'Agent tracking requires sessions launched with:' },
526
+ { label: ` ${ansi.cyan}claude --agent <name>${ansi.reset}` },
527
+ { label: '' },
528
+ { label: 'The agent_type is recorded on SessionStart and' },
529
+ { label: 'associated with all subsequent hook events in that session.' },
530
+ ];
531
+ }
532
+ if (state.drillDown && 'agent' in state.drillDown) {
533
+ const ag = state.drillDown;
534
+ const rows = [handlerHeader()];
535
+ for (const h of ag.handlers) {
536
+ if (query && !fuzzyMatch(query, h.handler))
537
+ continue;
538
+ rows.push({ label: makeHandlerRow(h) });
539
+ }
540
+ return rows;
541
+ }
542
+ const nameW = 22;
543
+ const rows = [{
544
+ label: `${pad('Agent', nameW)}${rpad('Fires', 8)}${rpad('Errors', 8)}${rpad('Avg ms', 9)}${rpad('Handlers', 10)}`,
545
+ isHeader: true,
546
+ }];
547
+ for (const ag of state.agentAggs) {
548
+ if (query && !fuzzyMatch(query, ag.agent))
549
+ continue;
550
+ const name = pad(ag.agent, nameW);
551
+ const fires = rpad(String(ag.fires), 8);
552
+ const errs = colorErrors(ag.errors, rpad(String(ag.errors), 8));
553
+ const avg = colorMs(ag.avgMs, rpad(ag.avgMs.toFixed(1), 9));
554
+ const hCount = rpad(String(ag.handlers.length), 10);
555
+ rows.push({ label: `${name}${fires}${errs}${avg}${hCount}` });
556
+ }
557
+ return rows;
558
+ }
559
+ function getProjectsRows(state, query) {
560
+ if (state.drillDown && 'project' in state.drillDown) {
561
+ const pr = state.drillDown;
562
+ const rows = [handlerHeader()];
563
+ for (const h of pr.handlers) {
564
+ if (query && !fuzzyMatch(query, h.handler))
565
+ continue;
566
+ rows.push({ label: makeHandlerRow(h) });
567
+ }
568
+ return rows;
569
+ }
570
+ const nameW = 22;
571
+ const rows = [{
572
+ label: `${pad('Session', nameW)}${rpad('Fires', 8)}${rpad('Errors', 8)}${rpad('Avg ms', 9)}`,
573
+ isHeader: true,
574
+ }];
575
+ for (const pr of state.projectAggs) {
576
+ if (query && !fuzzyMatch(query, pr.project))
577
+ continue;
578
+ const name = pad(pr.project, nameW);
579
+ const fires = rpad(String(pr.fires), 8);
580
+ const errs = colorErrors(pr.errors, rpad(String(pr.errors), 8));
581
+ const avg = colorMs(pr.avgMs, rpad(pr.avgMs.toFixed(1), 9));
582
+ rows.push({ label: `${name}${fires}${errs}${avg}` });
583
+ }
584
+ return rows;
585
+ }
586
+ // --- Rendering ---
587
+ function render(state) {
588
+ const { rows: termRows, cols } = getTermSize();
589
+ const allRows = getRows(state);
590
+ // Layout: line 1 = title + tabs, line 2 = separator, lines 3..N-3 = content,
591
+ // line N-2 = separator, line N-1 = search/status, line N = keybindings
592
+ const headerLines = 2;
593
+ const footerLines = 3;
594
+ const contentHeight = termRows - headerLines - footerLines;
595
+ write(ansi.cursorHome);
596
+ // --- Header line 1: title + tab bar ---
597
+ const drillLabel = state.drillDown
598
+ ? ` > ${('event' in state.drillDown) ? state.drillDown.event :
599
+ ('agent' in state.drillDown) ? state.drillDown.agent :
600
+ state.drillDown.project}`
601
+ : '';
602
+ let tabBar = '';
603
+ for (const v of VIEW_ORDER) {
604
+ if (v === state.currentView) {
605
+ tabBar += `${ansi.reverse}${ansi.bold} ${VIEW_LABELS[v]} ${ansi.reset} `;
606
+ }
607
+ else {
608
+ tabBar += `${ansi.dim} ${VIEW_LABELS[v]} ${ansi.reset} `;
609
+ }
610
+ }
611
+ const searchIndicator = state.searchQuery
612
+ ? ` ${ansi.yellow}/${state.searchQuery}${ansi.reset}`
613
+ : '';
614
+ const titleLeft = ` ${ansi.bold}clooks stats${ansi.reset}${drillLabel}`;
615
+ const titleRight = `q: quit `;
616
+ const headerLine = titleLeft + searchIndicator
617
+ + ' '.repeat(Math.max(1, cols - stripAnsi(titleLeft + searchIndicator).length - titleRight.length))
618
+ + `${ansi.dim}${titleRight}${ansi.reset}`;
619
+ write(ansi.eraseLine + headerLine + '\n');
620
+ // Tabs line
621
+ write(ansi.eraseLine + ' ' + tabBar + '\n');
622
+ // --- Separator ---
623
+ // (rendered as part of content area below)
624
+ // --- Content ---
625
+ // Adjust selectedIndex to skip header rows
626
+ // Find the first non-header index
627
+ let selectableStart = 0;
628
+ for (let i = 0; i < allRows.length; i++) {
629
+ if (!allRows[i].isHeader) {
630
+ selectableStart = i;
631
+ break;
632
+ }
633
+ }
634
+ // Clamp selectedIndex to selectable rows only
635
+ const selectableRows = allRows.filter(r => !r.isHeader);
636
+ if (state.selectedIndex >= selectableRows.length) {
637
+ state.selectedIndex = Math.max(0, selectableRows.length - 1);
638
+ }
639
+ // Map selectedIndex to absolute index in allRows
640
+ let selectableCount = 0;
641
+ let absSelectedIndex = -1;
642
+ for (let i = 0; i < allRows.length; i++) {
643
+ if (!allRows[i].isHeader) {
644
+ if (selectableCount === state.selectedIndex) {
645
+ absSelectedIndex = i;
646
+ break;
647
+ }
648
+ selectableCount++;
649
+ }
650
+ }
651
+ // Adjust scroll for absolute index
652
+ if (absSelectedIndex >= 0) {
653
+ if (absSelectedIndex < state.scrollOffset) {
654
+ state.scrollOffset = absSelectedIndex;
655
+ }
656
+ else if (absSelectedIndex >= state.scrollOffset + contentHeight) {
657
+ state.scrollOffset = absSelectedIndex - contentHeight + 1;
658
+ }
659
+ }
660
+ // Ensure header row (index 0) is always visible by starting scroll at 0 min
661
+ if (state.scrollOffset > 0 && allRows.length > 0 && allRows[0].isHeader) {
662
+ // Keep the header pinned — we'll render it separately
663
+ }
664
+ // Render content rows
665
+ const hasHeader = allRows.length > 0 && allRows[0].isHeader;
666
+ let renderedLines = 0;
667
+ if (hasHeader) {
668
+ // Pin the column header
669
+ const hdr = allRows[0].label;
670
+ write(ansi.eraseLine + ` ${ansi.dim}${ansi.underline}${hdr}${ansi.reset}` + '\n');
671
+ renderedLines++;
672
+ }
673
+ const dataStart = hasHeader ? 1 : 0;
674
+ const visibleCount = contentHeight - renderedLines;
675
+ // scrollOffset applies to data rows only (after header)
676
+ const dataRows = allRows.slice(dataStart);
677
+ const startIdx = state.scrollOffset;
678
+ const endIdx = Math.min(dataRows.length, startIdx + visibleCount);
679
+ // Map selected to data-relative index
680
+ const dataSelectedIdx = absSelectedIndex >= 0 ? absSelectedIndex - dataStart : -1;
681
+ for (let i = startIdx; i < endIdx; i++) {
682
+ const row = dataRows[i];
683
+ const isSelected = (i === dataSelectedIdx - state.scrollOffset + state.scrollOffset)
684
+ // Simpler: is this data index === absSelectedIndex - dataStart?
685
+ && false; // placeholder
686
+ // Check if this data row is selected
687
+ const selected = (i + dataStart) === absSelectedIndex;
688
+ if (selected) {
689
+ write(ansi.eraseLine + ` ${ansi.bold}${ansi.cyan}\u25b8${ansi.reset} ${ansi.bold}${row.label}${ansi.reset}` + '\n');
690
+ }
691
+ else {
692
+ write(ansi.eraseLine + ` ${row.label}` + '\n');
693
+ }
694
+ renderedLines++;
695
+ }
696
+ // Fill remaining lines with blanks
697
+ for (let i = renderedLines; i < contentHeight; i++) {
698
+ write(ansi.eraseLine + '\n');
699
+ }
700
+ // --- Footer ---
701
+ const totalFires = state.entries.length;
702
+ const totalErrors = state.entries.filter(e => !e.ok).length;
703
+ const totalEvents = state.eventAggs.length;
704
+ const totalHandlers = state.handlerAggs.length;
705
+ // Separator
706
+ write(ansi.eraseLine + ` ${ansi.dim}${'─'.repeat(Math.max(0, cols - 4))}${ansi.reset}` + '\n');
707
+ // Status line
708
+ const statusInfo = `${totalEvents} events ${totalHandlers} handlers ${totalFires} fires ${totalErrors} errors`;
709
+ write(ansi.eraseLine + ` ${ansi.dim}${statusInfo}${ansi.reset}` + '\n');
710
+ // Keybinding line
711
+ let keyHelp;
712
+ if (state.searchMode) {
713
+ keyHelp = 'Type to filter Enter: accept Esc: cancel';
714
+ }
715
+ else if (state.drillDown) {
716
+ keyHelp = 'Esc: back Tab: switch view j/k: navigate /: search q: quit';
717
+ }
718
+ else {
719
+ keyHelp = 'Tab: switch view j/k: navigate Enter: drill in /: search 1-4: jump to tab q: quit';
720
+ }
721
+ write(ansi.eraseLine + ` ${ansi.dim}${keyHelp}${ansi.reset}`);
722
+ // If in search mode, show the cursor in the search field
723
+ if (state.searchMode) {
724
+ // Position cursor at the search indicator in header
725
+ write(ansi.showCursor);
726
+ }
727
+ else {
728
+ write(ansi.hideCursor);
729
+ }
730
+ }