@mauribadnights/clooks 0.4.0 → 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 CHANGED
@@ -1,13 +1,47 @@
1
1
  "use strict";
2
2
  // clooks interactive TUI dashboard for `clooks stats -i`
3
- var __importDefault = (this && this.__importDefault) || function (mod) {
4
- return (mod && mod.__esModule) ? mod : { "default": mod };
5
- };
3
+ // Modern terminal UI using raw ANSI escape codes + Node readline
6
4
  Object.defineProperty(exports, "__esModule", { value: true });
7
5
  exports.launchDashboard = launchDashboard;
8
- const blessed_1 = __importDefault(require("blessed"));
9
6
  const fs_1 = require("fs");
10
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
+ }
11
45
  // --- Data loading ---
12
46
  function loadAllMetricEntries() {
13
47
  if (!(0, fs_1.existsSync)(constants_js_1.METRICS_FILE))
@@ -83,53 +117,37 @@ function aggregateAllHandlers(entries) {
83
117
  return result.sort((a, b) => b.avgMs - a.avgMs);
84
118
  }
85
119
  function aggregateByAgent(entries) {
86
- // Group by session_id first, then infer agent from handler patterns
87
- // Since MetricEntry doesn't have agent_type, we group by session_id
88
- // and label sessions by the set of handlers that fired
89
- const bySid = new Map();
120
+ // Group by actual agent_type field from metrics
121
+ const byAgent = new Map();
122
+ let hasAgentData = false;
90
123
  for (const e of entries) {
91
- const sid = e.session_id ?? 'unknown';
92
- const arr = bySid.get(sid) ?? [];
124
+ const agent = e.agent_type ?? '';
125
+ if (agent)
126
+ hasAgentData = true;
127
+ const arr = byAgent.get(agent) ?? [];
93
128
  arr.push(e);
94
- bySid.set(sid, arr);
95
- }
96
- // Derive agent name from handler prefixes (e.g., "gsd-*" => "gsd", "cq-*" => "cq")
97
- const agentMap = new Map();
98
- for (const [_sid, sEntries] of bySid) {
99
- // Use the most common handler prefix as agent name
100
- const prefixes = new Map();
101
- for (const e of sEntries) {
102
- const prefix = e.handler.split('-')[0] || e.handler;
103
- prefixes.set(prefix, (prefixes.get(prefix) ?? 0) + 1);
104
- }
105
- let agent = 'unknown';
106
- let maxCount = 0;
107
- for (const [p, c] of prefixes) {
108
- if (c > maxCount) {
109
- agent = p;
110
- maxCount = c;
111
- }
112
- }
113
- const arr = agentMap.get(agent) ?? [];
114
- arr.push(...sEntries);
115
- agentMap.set(agent, arr);
129
+ byAgent.set(agent, arr);
116
130
  }
131
+ // If no entries have agent_type, return empty — the view will show a message
132
+ if (!hasAgentData)
133
+ return [];
117
134
  const result = [];
118
- for (const [agent, aEntries] of agentMap) {
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);
119
139
  const handlers = aggregateHandlersFrom(aEntries);
120
140
  result.push({
121
141
  agent,
122
142
  fires: aEntries.length,
123
143
  errors: aEntries.filter((e) => !e.ok).length,
144
+ avgMs: durations.reduce((a, b) => a + b, 0) / durations.length,
124
145
  handlers,
125
146
  });
126
147
  }
127
148
  return result.sort((a, b) => b.fires - a.fires);
128
149
  }
129
150
  function aggregateByProject(entries) {
130
- // MetricEntry doesn't have cwd, so we group by session_id and use 'unknown' for project
131
- // If entries had cwd, we'd group by that. For now, group by session_id as proxy.
132
- // Actually, let's check if there's a cwd-like field or group by session
133
151
  const bySid = new Map();
134
152
  for (const e of entries) {
135
153
  const key = e.session_id ?? 'unknown';
@@ -141,7 +159,7 @@ function aggregateByProject(entries) {
141
159
  for (const [sid, pEntries] of bySid) {
142
160
  const durations = pEntries.map((e) => e.duration_ms);
143
161
  const handlers = aggregateHandlersFrom(pEntries);
144
- const label = sid.length > 16 ? sid.slice(0, 8) + '...' + sid.slice(-8) : sid;
162
+ const label = sid.length > 16 ? sid.slice(0, 8) + '..' + sid.slice(-8) : sid;
145
163
  result.push({
146
164
  project: label,
147
165
  fires: pEntries.length,
@@ -184,18 +202,34 @@ function fuzzyMatch(query, target) {
184
202
  }
185
203
  return qi === q.length;
186
204
  }
187
- // --- Color coding ---
188
- function colorize(avgMs, text) {
189
- if (avgMs < 5)
190
- return `{green-fg}${text}{/green-fg}`;
191
- if (avgMs <= 50)
192
- return `{yellow-fg}${text}{/yellow-fg}`;
193
- return `{red-fg}${text}{/red-fg}`;
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}`;
194
212
  }
195
- function colorizeErrors(errors, text) {
213
+ function colorErrors(errors, text) {
196
214
  if (errors === 0)
197
- return `{green-fg}${text}{/green-fg}`;
198
- return `{red-fg}${text}{/red-fg}`;
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);
199
233
  }
200
234
  const VIEW_ORDER = ['events', 'handlers', 'agents', 'projects'];
201
235
  const VIEW_LABELS = {
@@ -203,358 +237,494 @@ const VIEW_LABELS = {
203
237
  handlers: 'Handlers',
204
238
  agents: 'Agents',
205
239
  projects: 'Projects',
206
- search: 'Search',
207
240
  };
241
+ // --- Main TUI ---
208
242
  function launchDashboard() {
209
243
  const entries = loadAllMetricEntries();
210
- const eventAggs = aggregateByEvent(entries);
211
- const handlerAggs = aggregateAllHandlers(entries);
212
- const agentAggs = aggregateByAgent(entries);
213
- const projectAggs = aggregateByProject(entries);
214
- const totalFires = entries.length;
215
- const totalErrors = entries.filter((e) => !e.ok).length;
216
- const totalEvents = eventAggs.length;
217
- const totalHandlers = handlerAggs.length;
218
- // --- Screen setup ---
219
- const scr = blessed_1.default.screen({
220
- smartCSR: true,
221
- title: 'clooks stats',
222
- fullUnicode: true,
223
- });
224
- let currentView = 'events';
225
- let drillDown = null;
226
- let searchMode = false;
227
- let searchQuery = '';
228
- // --- Header (tab bar) ---
229
- const header = blessed_1.default.box({
230
- top: 0,
231
- left: 0,
232
- width: '100%',
233
- height: 3,
234
- tags: true,
235
- border: { type: 'line' },
236
- style: {
237
- border: { fg: 'cyan' },
238
- },
239
- });
240
- scr.append(header);
241
- // --- Main list ---
242
- const mainList = blessed_1.default.list({
243
- top: 3,
244
- left: 0,
245
- width: '100%',
246
- height: '100%-6',
247
- tags: true,
248
- keys: true,
249
- vi: true,
250
- mouse: true,
251
- scrollable: true,
252
- border: { type: 'line' },
253
- style: {
254
- border: { fg: 'blue' },
255
- selected: { bg: 'blue', fg: 'white', bold: true },
256
- item: { fg: 'white' },
257
- },
258
- scrollbar: {
259
- ch: ' ',
260
- style: { bg: 'blue' },
261
- },
262
- });
263
- scr.append(mainList);
264
- // --- Footer (status bar) ---
265
- const footer = blessed_1.default.box({
266
- bottom: 0,
267
- left: 0,
268
- width: '100%',
269
- height: 3,
270
- tags: true,
271
- border: { type: 'line' },
272
- style: {
273
- border: { fg: 'cyan' },
274
- },
275
- });
276
- scr.append(footer);
277
- // --- Search input (hidden by default) ---
278
- const searchBox = blessed_1.default.textbox({
279
- bottom: 3,
280
- left: 0,
281
- width: '100%',
282
- height: 3,
283
- tags: true,
284
- border: { type: 'line' },
285
- style: {
286
- border: { fg: 'yellow' },
287
- fg: 'white',
288
- },
289
- label: ' Search (Esc to cancel) ',
290
- hidden: true,
291
- inputOnFocus: true,
292
- });
293
- scr.append(searchBox);
294
- // --- Rendering functions ---
295
- function renderHeader() {
296
- const tabs = VIEW_ORDER.map((v) => {
297
- if (v === currentView)
298
- return `{cyan-fg}{bold}[${VIEW_LABELS[v]}]{/bold}{/cyan-fg}`;
299
- return ` ${VIEW_LABELS[v]} `;
300
- });
301
- if (searchMode)
302
- tabs.push(`{yellow-fg}{bold}[Search]{/bold}{/yellow-fg}`);
303
- header.setContent(` ${tabs.join(' ')}`);
304
- }
305
- function renderFooter() {
306
- const statusLine = `Total: ${totalEvents} events | ${totalHandlers} handlers | ${totalFires} fires | ${totalErrors} errors`;
307
- const helpLine = 'Tab=switch view /=search Enter=drill in Esc=back q=quit';
308
- footer.setContent(` ${statusLine}\n ${helpLine}`);
309
- }
310
- function formatHandlerRow(h) {
311
- const name = h.handler.padEnd(28);
312
- const ev = h.event.padEnd(18);
313
- const fires = String(h.fires).padStart(6);
314
- const errs = colorizeErrors(h.errors, String(h.errors).padStart(6));
315
- const avg = colorize(h.avgMs, h.avgMs.toFixed(1).padStart(8));
316
- const max = colorize(h.maxMs, h.maxMs.toFixed(1).padStart(8));
317
- return ` ${name} ${ev} ${fires} ${errs} ${avg} ${max}`;
318
- }
319
- function handlerTableHeader() {
320
- const name = 'Handler'.padEnd(28);
321
- const ev = 'Event'.padEnd(18);
322
- const fires = 'Fires'.padStart(6);
323
- const errs = 'Errors'.padStart(6);
324
- const avg = 'Avg ms'.padStart(8);
325
- const max = 'Max ms'.padStart(8);
326
- return `{bold} ${name} ${ev} ${fires} ${errs} ${avg} ${max}{/bold}`;
327
- }
328
- function renderEventsView() {
329
- if (drillDown && 'event' in drillDown && 'handlers' in drillDown) {
330
- const ev = drillDown;
331
- mainList.setLabel(` Event: ${ev.event} `);
332
- const items = [handlerTableHeader()];
333
- for (const h of ev.handlers) {
334
- items.push(formatHandlerRow(h));
335
- }
336
- mainList.setItems(items);
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);
337
279
  }
338
280
  else {
339
- mainList.setLabel(' Events ');
340
- const items = [];
341
- for (const ev of eventAggs) {
342
- const name = ev.event.padEnd(22);
343
- const fires = String(ev.fires).padStart(6);
344
- const errs = colorizeErrors(ev.errors, String(ev.errors).padStart(6));
345
- const avg = colorize(ev.avgMs, ev.avgMs.toFixed(1).padStart(8));
346
- const max = colorize(ev.maxMs, ev.maxMs.toFixed(1).padStart(8));
347
- const hCount = `(${ev.handlers.length} handlers)`.padStart(14);
348
- items.push(` ${name} ${fires} fires ${errs} errs ${avg} avg ${max} max ${hCount}`);
349
- }
350
- mainList.setItems(items);
281
+ handleNormalInput(state, key, cleanup);
351
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;
352
293
  }
353
- function renderHandlersView() {
354
- mainList.setLabel(' Handlers (sorted by avg latency) ');
355
- const items = [handlerTableHeader()];
356
- for (const h of handlerAggs) {
357
- items.push(formatHandlerRow(h));
358
- }
359
- mainList.setItems(items);
360
- }
361
- function renderAgentsView() {
362
- if (drillDown && 'agent' in drillDown) {
363
- const ag = drillDown;
364
- mainList.setLabel(` Agent: ${ag.agent} `);
365
- const items = [handlerTableHeader()];
366
- for (const h of ag.handlers) {
367
- items.push(formatHandlerRow(h));
368
- }
369
- mainList.setItems(items);
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;
370
389
  }
371
- else {
372
- mainList.setLabel(' Agents ');
373
- const items = [];
374
- for (const ag of agentAggs) {
375
- const name = ag.agent.padEnd(22);
376
- const fires = String(ag.fires).padStart(6);
377
- const errs = colorizeErrors(ag.errors, String(ag.errors).padStart(6));
378
- const hCount = `(${ag.handlers.length} handlers)`.padStart(14);
379
- items.push(` ${name} ${fires} fires ${errs} errs ${hCount}`);
380
- }
381
- mainList.setItems(items);
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;
382
397
  }
383
398
  }
384
- function renderProjectsView() {
385
- if (drillDown && 'project' in drillDown) {
386
- const pr = drillDown;
387
- mainList.setLabel(` Session: ${pr.project} `);
388
- const items = [handlerTableHeader()];
389
- for (const h of pr.handlers) {
390
- items.push(formatHandlerRow(h));
391
- }
392
- mainList.setItems(items);
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;
393
405
  }
394
- else {
395
- mainList.setLabel(' Projects / Sessions ');
396
- const items = [];
397
- for (const pr of projectAggs) {
398
- const name = pr.project.padEnd(22);
399
- const fires = String(pr.fires).padStart(6);
400
- const errs = colorizeErrors(pr.errors, String(pr.errors).padStart(6));
401
- const avg = colorize(pr.avgMs, pr.avgMs.toFixed(1).padStart(8));
402
- items.push(` ${name} ${fires} fires ${errs} errs ${avg} avg`);
403
- }
404
- mainList.setItems(items);
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) });
405
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) });
406
517
  }
407
- function renderSearchView() {
408
- mainList.setLabel(` Search: "${searchQuery}" `);
409
- if (!searchQuery) {
410
- mainList.setItems([' Type to search handlers, events, agents...']);
411
- return;
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) });
412
539
  }
413
- const results = [handlerTableHeader()];
414
- // Search handlers
415
- for (const h of handlerAggs) {
416
- if (fuzzyMatch(searchQuery, h.handler) || fuzzyMatch(searchQuery, h.event)) {
417
- results.push(formatHandlerRow(h));
418
- }
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) });
419
567
  }
420
- // Search events
421
- for (const ev of eventAggs) {
422
- if (fuzzyMatch(searchQuery, ev.event) && !results.some((r) => r.includes(ev.event))) {
423
- const name = ev.event.padEnd(22);
424
- results.push(` {cyan-fg}[event]{/cyan-fg} ${name} ${ev.fires} fires`);
425
- }
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} `;
426
606
  }
427
- // Search agents
428
- for (const ag of agentAggs) {
429
- if (fuzzyMatch(searchQuery, ag.agent)) {
430
- const name = ag.agent.padEnd(22);
431
- results.push(` {magenta-fg}[agent]{/magenta-fg} ${name} ${ag.fires} fires`);
432
- }
607
+ else {
608
+ tabBar += `${ansi.dim} ${VIEW_LABELS[v]} ${ansi.reset} `;
433
609
  }
434
- if (results.length === 1) {
435
- results.push(' No matches found.');
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;
436
632
  }
437
- mainList.setItems(results);
438
- }
439
- function renderCurrentView() {
440
- renderHeader();
441
- renderFooter();
442
- switch (currentView) {
443
- case 'events':
444
- renderEventsView();
445
- break;
446
- case 'handlers':
447
- renderHandlersView();
448
- break;
449
- case 'agents':
450
- renderAgentsView();
451
- break;
452
- case 'projects':
453
- renderProjectsView();
454
- break;
455
- case 'search':
456
- renderSearchView();
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;
457
646
  break;
458
- }
459
- mainList.select(0);
460
- mainList.focus();
461
- scr.render();
462
- }
463
- function switchView(direction) {
464
- if (searchMode) {
465
- searchMode = false;
466
- searchBox.hide();
467
- }
468
- drillDown = null;
469
- const idx = VIEW_ORDER.indexOf(currentView);
470
- const next = (idx + direction + VIEW_ORDER.length) % VIEW_ORDER.length;
471
- currentView = VIEW_ORDER[next];
472
- renderCurrentView();
473
- }
474
- function drillInto() {
475
- // @ts-ignore — blessed ListElement exposes .selected at runtime
476
- const selected = mainList.selected ?? 0;
477
- if (currentView === 'events' && !drillDown) {
478
- const ev = eventAggs[selected];
479
- if (ev) {
480
- drillDown = ev;
481
- renderCurrentView();
482
647
  }
648
+ selectableCount++;
483
649
  }
484
- else if (currentView === 'agents' && !drillDown) {
485
- const ag = agentAggs[selected];
486
- if (ag) {
487
- drillDown = ag;
488
- renderCurrentView();
489
- }
650
+ }
651
+ // Adjust scroll for absolute index
652
+ if (absSelectedIndex >= 0) {
653
+ if (absSelectedIndex < state.scrollOffset) {
654
+ state.scrollOffset = absSelectedIndex;
490
655
  }
491
- else if (currentView === 'projects' && !drillDown) {
492
- const pr = projectAggs[selected];
493
- if (pr) {
494
- drillDown = pr;
495
- renderCurrentView();
496
- }
656
+ else if (absSelectedIndex >= state.scrollOffset + contentHeight) {
657
+ state.scrollOffset = absSelectedIndex - contentHeight + 1;
497
658
  }
498
659
  }
499
- function goBack() {
500
- if (searchMode) {
501
- searchMode = false;
502
- searchBox.hide();
503
- currentView = 'events';
504
- renderCurrentView();
505
- return;
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');
506
690
  }
507
- if (drillDown) {
508
- drillDown = null;
509
- renderCurrentView();
691
+ else {
692
+ write(ansi.eraseLine + ` ${row.label}` + '\n');
510
693
  }
694
+ renderedLines++;
511
695
  }
512
- // --- Key bindings ---
513
- scr.key(['q', 'C-c'], () => process.exit(0));
514
- scr.key(['tab'], () => switchView(1));
515
- scr.key(['S-tab'], () => switchView(-1));
516
- scr.key(['enter'], () => drillInto());
517
- scr.key(['escape', 'backspace'], () => goBack());
518
- scr.key(['/'], () => {
519
- searchMode = true;
520
- currentView = 'search';
521
- searchQuery = '';
522
- searchBox.show();
523
- searchBox.setValue('');
524
- renderCurrentView();
525
- searchBox.focus();
526
- searchBox.readInput(() => {
527
- // Input submitted
528
- searchQuery = searchBox.getValue();
529
- searchBox.hide();
530
- mainList.focus();
531
- renderCurrentView();
532
- });
533
- scr.render();
534
- });
535
- // Live search: update on each keypress in the search box
536
- searchBox.on('keypress', (_ch, _key) => {
537
- // Small delay to let blessed update the value
538
- setTimeout(() => {
539
- searchQuery = searchBox.getValue();
540
- renderSearchView();
541
- scr.render();
542
- }, 10);
543
- });
544
- // --- No data state ---
545
- if (entries.length === 0) {
546
- mainList.setItems([
547
- ' No metrics recorded yet.',
548
- '',
549
- ' Start the clooks daemon and run some Claude Code sessions',
550
- ' to see hook execution statistics here.',
551
- ]);
552
- mainList.setLabel(' clooks stats ');
553
- renderHeader();
554
- renderFooter();
555
- scr.render();
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);
556
726
  }
557
727
  else {
558
- renderCurrentView();
728
+ write(ansi.hideCursor);
559
729
  }
560
730
  }