@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/README.md +276 -201
- package/dist/cli.js +1 -1
- package/dist/server.js +1 -0
- package/dist/tui.js +541 -371
- package/dist/types.d.ts +1 -0
- package/docs/DASHBOARD-VISION.md +66 -0
- package/docs/DEFERRED-FIXES.md +35 -0
- package/docs/architecture.png +0 -0
- package/docs/generate-diagram.py +177 -0
- package/package.json +2 -3
package/dist/tui.js
CHANGED
|
@@ -1,13 +1,47 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// clooks interactive TUI dashboard for `clooks stats -i`
|
|
3
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
92
|
-
|
|
124
|
+
const agent = e.agent_type ?? '';
|
|
125
|
+
if (agent)
|
|
126
|
+
hasAgentData = true;
|
|
127
|
+
const arr = byAgent.get(agent) ?? [];
|
|
93
128
|
arr.push(e);
|
|
94
|
-
|
|
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
|
|
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) + '
|
|
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
|
|
188
|
-
function
|
|
189
|
-
if (
|
|
190
|
-
return
|
|
191
|
-
if (
|
|
192
|
-
return
|
|
193
|
-
return
|
|
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
|
|
213
|
+
function colorErrors(errors, text) {
|
|
196
214
|
if (errors === 0)
|
|
197
|
-
return
|
|
198
|
-
return
|
|
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
|
|
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
|
-
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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 (
|
|
492
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
renderCurrentView();
|
|
691
|
+
else {
|
|
692
|
+
write(ansi.eraseLine + ` ${row.label}` + '\n');
|
|
510
693
|
}
|
|
694
|
+
renderedLines++;
|
|
511
695
|
}
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
728
|
+
write(ansi.hideCursor);
|
|
559
729
|
}
|
|
560
730
|
}
|