@luanpdd/kit-mcp 1.1.0 → 1.2.3

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.
@@ -0,0 +1,1022 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>kit-mcp sidecar</title>
7
+ <style>
8
+ /* Design tokens */
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #f8fafc;
12
+ --bg-elev: #ffffff;
13
+ --bg-row: #ffffff;
14
+ --bg-row-hover: #f1f5f9;
15
+ --fg: #0f172a;
16
+ --fg-muted: #475569;
17
+ --fg-subtle: #64748b;
18
+ --border: #e2e8f0;
19
+ --accent: #3b82f6;
20
+ --ok: #10b981;
21
+ --warn: #f59e0b;
22
+ --err: #ef4444;
23
+ --info: #6366f1;
24
+ --shadow: 0 1px 2px rgba(0,0,0,.04), 0 4px 10px rgba(0,0,0,.04);
25
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
26
+ --sans: system-ui, -apple-system, "Segoe UI", Roboto, Inter, "Helvetica Neue", sans-serif;
27
+ }
28
+ @media (prefers-color-scheme: dark) {
29
+ :root {
30
+ --bg: #0b1120;
31
+ --bg-elev: #111827;
32
+ --bg-row: #0f172a;
33
+ --bg-row-hover: #1e293b;
34
+ --fg: #e2e8f0;
35
+ --fg-muted: #94a3b8;
36
+ --fg-subtle: #64748b;
37
+ --border: #1f2937;
38
+ --accent: #60a5fa;
39
+ --ok: #34d399;
40
+ --warn: #fbbf24;
41
+ --err: #f87171;
42
+ --info: #818cf8;
43
+ --shadow: 0 1px 2px rgba(0,0,0,.3), 0 4px 10px rgba(0,0,0,.3);
44
+ }
45
+ }
46
+
47
+ * { box-sizing: border-box; }
48
+ html, body { margin: 0; padding: 0; height: 100%; }
49
+ body {
50
+ font-family: var(--sans);
51
+ font-size: 14px;
52
+ line-height: 1.5;
53
+ color: var(--fg);
54
+ background: var(--bg);
55
+ display: flex;
56
+ flex-direction: column;
57
+ }
58
+
59
+ header {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 12px;
63
+ padding: 10px 16px;
64
+ border-bottom: 1px solid var(--border);
65
+ background: var(--bg-elev);
66
+ box-shadow: var(--shadow);
67
+ position: sticky;
68
+ top: 0;
69
+ z-index: 10;
70
+ }
71
+ header h1 {
72
+ margin: 0;
73
+ font-size: 14px;
74
+ font-weight: 600;
75
+ letter-spacing: .2px;
76
+ }
77
+ header h1::before {
78
+ content: "◆";
79
+ color: var(--accent);
80
+ margin-right: 6px;
81
+ }
82
+ header .meta {
83
+ color: var(--fg-subtle);
84
+ font-family: var(--mono);
85
+ font-size: 12px;
86
+ }
87
+ header .grow { flex: 1; }
88
+
89
+ .conn-status {
90
+ display: inline-flex;
91
+ align-items: center;
92
+ gap: 6px;
93
+ padding: 3px 10px;
94
+ border-radius: 999px;
95
+ font-family: var(--mono);
96
+ font-size: 11px;
97
+ font-weight: 600;
98
+ border: 1px solid var(--border);
99
+ background: var(--bg-row);
100
+ }
101
+ .conn-status .dot {
102
+ display: inline-block;
103
+ width: 8px;
104
+ height: 8px;
105
+ border-radius: 50%;
106
+ background: var(--fg-subtle);
107
+ }
108
+ .conn-status[data-state="CONNECTING"] .dot { background: var(--warn); animation: pulse 1.4s infinite; }
109
+ .conn-status[data-state="OPEN"] .dot { background: var(--ok); }
110
+ .conn-status[data-state="CLOSED"] .dot { background: var(--err); animation: pulse 1s infinite; }
111
+ @keyframes pulse { 50% { opacity: .35; } }
112
+
113
+ .toolbar {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 10px;
117
+ padding: 10px 16px;
118
+ border-bottom: 1px solid var(--border);
119
+ background: var(--bg-elev);
120
+ flex-wrap: wrap;
121
+ }
122
+ .toolbar input[type="search"] {
123
+ flex: 1 1 240px;
124
+ min-width: 180px;
125
+ padding: 6px 10px;
126
+ border: 1px solid var(--border);
127
+ border-radius: 6px;
128
+ background: var(--bg-row);
129
+ color: var(--fg);
130
+ font-family: var(--sans);
131
+ font-size: 13px;
132
+ }
133
+ .toolbar input[type="search"]:focus {
134
+ outline: 2px solid var(--accent);
135
+ outline-offset: -1px;
136
+ }
137
+ .filters {
138
+ display: flex;
139
+ flex-wrap: wrap;
140
+ gap: 4px;
141
+ }
142
+ .filters label {
143
+ display: inline-flex;
144
+ align-items: center;
145
+ gap: 4px;
146
+ padding: 3px 8px;
147
+ border-radius: 999px;
148
+ border: 1px solid var(--border);
149
+ background: var(--bg-row);
150
+ cursor: pointer;
151
+ user-select: none;
152
+ font-size: 11px;
153
+ font-family: var(--mono);
154
+ }
155
+ .filters input { display: none; }
156
+ .filters label:has(input:checked) {
157
+ background: var(--accent);
158
+ color: white;
159
+ border-color: var(--accent);
160
+ }
161
+
162
+ button {
163
+ padding: 5px 12px;
164
+ border: 1px solid var(--border);
165
+ border-radius: 6px;
166
+ background: var(--bg-row);
167
+ color: var(--fg);
168
+ font-family: var(--sans);
169
+ font-size: 12px;
170
+ cursor: pointer;
171
+ }
172
+ button:hover { background: var(--bg-row-hover); }
173
+ button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
174
+ button[aria-pressed="true"] { background: var(--accent); color: white; border-color: var(--accent); }
175
+
176
+ main {
177
+ flex: 1;
178
+ overflow: auto;
179
+ padding: 0 16px 24px;
180
+ }
181
+
182
+ .empty {
183
+ text-align: center;
184
+ color: var(--fg-subtle);
185
+ padding: 64px 16px;
186
+ font-size: 14px;
187
+ }
188
+ .empty strong { display: block; color: var(--fg-muted); margin-bottom: 6px; }
189
+ .empty code {
190
+ font-family: var(--mono);
191
+ background: var(--bg-row);
192
+ padding: 1px 6px;
193
+ border-radius: 4px;
194
+ font-size: 12px;
195
+ border: 1px solid var(--border);
196
+ }
197
+
198
+ .ev-list {
199
+ list-style: none;
200
+ margin: 12px 0 0;
201
+ padding: 0;
202
+ border: 1px solid var(--border);
203
+ border-radius: 8px;
204
+ overflow: hidden;
205
+ background: var(--bg-elev);
206
+ }
207
+ .ev-row {
208
+ display: grid;
209
+ grid-template-columns: 92px 110px 1fr;
210
+ gap: 12px;
211
+ padding: 8px 12px;
212
+ border-bottom: 1px solid var(--border);
213
+ font-size: 13px;
214
+ align-items: start;
215
+ }
216
+ .ev-row:last-child { border-bottom: 0; }
217
+ .ev-row:hover { background: var(--bg-row-hover); }
218
+
219
+ .ev-time {
220
+ font-family: var(--mono);
221
+ font-size: 11px;
222
+ color: var(--fg-subtle);
223
+ padding-top: 1px;
224
+ }
225
+
226
+ .ev-badge {
227
+ display: inline-block;
228
+ padding: 2px 8px;
229
+ border-radius: 999px;
230
+ background: var(--bg-row);
231
+ color: var(--fg-muted);
232
+ font-family: var(--mono);
233
+ font-size: 10px;
234
+ font-weight: 700;
235
+ text-transform: uppercase;
236
+ letter-spacing: .5px;
237
+ border: 1px solid var(--border);
238
+ white-space: nowrap;
239
+ text-align: center;
240
+ }
241
+ .ev-badge[data-type="run.start"] { color: var(--info); border-color: var(--info); }
242
+ .ev-badge[data-type="run.end"] { color: var(--info); border-color: var(--info); }
243
+ .ev-badge[data-type="tool_invocation"]{ color: var(--accent); border-color: var(--accent); }
244
+ .ev-badge[data-type="progress"] { color: var(--ok); border-color: var(--ok); }
245
+ .ev-badge[data-type="milestone"] { color: var(--warn); border-color: var(--warn); }
246
+ .ev-badge[data-type="error"] { color: var(--err); border-color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); }
247
+ .ev-badge[data-type="shutdown"] { color: var(--err); border-color: var(--err); }
248
+
249
+ .ev-body { min-width: 0; word-wrap: break-word; }
250
+ .ev-body summary {
251
+ list-style: none;
252
+ cursor: pointer;
253
+ }
254
+ .ev-body summary::-webkit-details-marker { display: none; }
255
+ .ev-body summary .label {
256
+ color: var(--fg);
257
+ font-family: var(--sans);
258
+ }
259
+ .ev-body summary .meta {
260
+ color: var(--fg-subtle);
261
+ font-family: var(--mono);
262
+ font-size: 11px;
263
+ margin-left: 6px;
264
+ }
265
+ .ev-body pre {
266
+ margin: 6px 0 0;
267
+ padding: 8px 10px;
268
+ background: var(--bg-row);
269
+ border: 1px solid var(--border);
270
+ border-radius: 6px;
271
+ font-family: var(--mono);
272
+ font-size: 11px;
273
+ color: var(--fg-muted);
274
+ overflow-x: auto;
275
+ max-height: 280px;
276
+ }
277
+
278
+ /* Active runs panel — current executions with live progress bars */
279
+ .runs {
280
+ margin: 12px 0 16px;
281
+ display: grid;
282
+ gap: 10px;
283
+ }
284
+ .runs[hidden] { display: none; }
285
+ .runs-header {
286
+ display: flex;
287
+ align-items: center;
288
+ gap: 8px;
289
+ color: var(--fg-subtle);
290
+ font-family: var(--mono);
291
+ font-size: 11px;
292
+ text-transform: uppercase;
293
+ letter-spacing: .8px;
294
+ }
295
+ .runs-header .count {
296
+ background: var(--accent);
297
+ color: white;
298
+ padding: 1px 8px;
299
+ border-radius: 999px;
300
+ font-weight: 700;
301
+ }
302
+
303
+ .run-card {
304
+ background: var(--bg-elev);
305
+ border: 1px solid var(--border);
306
+ border-radius: 10px;
307
+ padding: 14px 16px;
308
+ box-shadow: var(--shadow);
309
+ display: grid;
310
+ grid-template-columns: minmax(0, 1fr) auto;
311
+ gap: 10px 16px;
312
+ align-items: baseline;
313
+ position: relative;
314
+ overflow: hidden;
315
+ transition: opacity .25s ease;
316
+ min-width: 0;
317
+ }
318
+ .run-card > * { min-width: 0; }
319
+ .run-card[data-status="running"] {
320
+ border-color: var(--accent);
321
+ border-left-width: 4px;
322
+ }
323
+ .run-card[data-status="done"] {
324
+ border-color: var(--ok);
325
+ border-left-width: 4px;
326
+ }
327
+ .run-card[data-status="error"] {
328
+ border-color: var(--err);
329
+ border-left-width: 4px;
330
+ background: color-mix(in srgb, var(--err) 5%, var(--bg-elev));
331
+ }
332
+ .run-card.fading { opacity: .4; }
333
+
334
+ .run-card .run-title {
335
+ font-family: var(--sans);
336
+ font-weight: 600;
337
+ font-size: 14px;
338
+ color: var(--fg);
339
+ display: flex;
340
+ align-items: center;
341
+ gap: 8px;
342
+ }
343
+ .run-card .run-kind {
344
+ font-size: 11px;
345
+ font-family: var(--mono);
346
+ color: var(--fg-subtle);
347
+ text-transform: uppercase;
348
+ letter-spacing: .5px;
349
+ background: var(--bg-row);
350
+ padding: 1px 8px;
351
+ border-radius: 999px;
352
+ border: 1px solid var(--border);
353
+ }
354
+ .run-card .run-percent {
355
+ font-family: var(--mono);
356
+ font-size: 22px;
357
+ font-weight: 700;
358
+ font-variant-numeric: tabular-nums;
359
+ color: var(--accent);
360
+ line-height: 1;
361
+ }
362
+ .run-card[data-status="done"] .run-percent { color: var(--ok); }
363
+ .run-card[data-status="error"] .run-percent { color: var(--err); font-size: 13px; }
364
+
365
+ .run-card .run-bar-wrap {
366
+ grid-column: 1 / -1;
367
+ height: 8px;
368
+ background: var(--bg-row);
369
+ border-radius: 4px;
370
+ overflow: hidden;
371
+ border: 1px solid var(--border);
372
+ position: relative;
373
+ }
374
+ .run-card .run-bar {
375
+ height: 100%;
376
+ background: var(--accent);
377
+ border-radius: 3px;
378
+ transition: width .25s ease;
379
+ position: relative;
380
+ }
381
+ .run-card[data-status="done"] .run-bar { background: var(--ok); }
382
+ .run-card[data-status="error"] .run-bar { background: var(--err); }
383
+ .run-card[data-status="running"] .run-bar::after {
384
+ /* Subtle moving stripe so the bar feels alive even when % stays put briefly */
385
+ content: '';
386
+ position: absolute; inset: 0;
387
+ background-image: linear-gradient(
388
+ 90deg,
389
+ transparent 0%, transparent 40%,
390
+ color-mix(in srgb, white 25%, transparent) 50%,
391
+ transparent 60%, transparent 100%
392
+ );
393
+ background-size: 200% 100%;
394
+ animation: shimmer 1.6s linear infinite;
395
+ }
396
+ @keyframes shimmer {
397
+ 0% { background-position: 200% 0; }
398
+ 100% { background-position: -200% 0; }
399
+ }
400
+
401
+ .run-card .run-step {
402
+ grid-column: 1 / -1;
403
+ color: var(--fg-muted);
404
+ font-family: var(--mono);
405
+ font-size: 12px;
406
+ word-break: break-word;
407
+ }
408
+ .run-card .run-meta {
409
+ grid-column: 1 / -1;
410
+ display: flex;
411
+ gap: 12px;
412
+ color: var(--fg-subtle);
413
+ font-family: var(--mono);
414
+ font-size: 10px;
415
+ }
416
+ .run-card .run-meta .runid { letter-spacing: .5px; }
417
+ .run-card[data-status="error"] .run-step { color: var(--err); }
418
+
419
+ .banner {
420
+ margin: 12px 0 0;
421
+ padding: 12px 16px;
422
+ border-radius: 8px;
423
+ background: color-mix(in srgb, var(--err) 8%, var(--bg-elev));
424
+ border: 1px solid var(--err);
425
+ color: var(--err);
426
+ font-size: 13px;
427
+ }
428
+ .banner[hidden] { display: none; }
429
+ .banner code {
430
+ font-family: var(--mono);
431
+ background: var(--bg-row);
432
+ padding: 1px 5px;
433
+ border-radius: 4px;
434
+ color: var(--fg-muted);
435
+ }
436
+
437
+ .footer {
438
+ padding: 8px 16px;
439
+ border-top: 1px solid var(--border);
440
+ background: var(--bg-elev);
441
+ color: var(--fg-subtle);
442
+ font-family: var(--mono);
443
+ font-size: 11px;
444
+ display: flex;
445
+ gap: 16px;
446
+ flex-wrap: wrap;
447
+ }
448
+ </style>
449
+ </head>
450
+ <body>
451
+ <header>
452
+ <h1>kit-mcp sidecar</h1>
453
+ <span class="meta" id="meta-port">porta —</span>
454
+ <span class="grow"></span>
455
+ <span class="conn-status" id="conn" data-state="CONNECTING"><span class="dot"></span><span id="conn-text">CONECTANDO</span></span>
456
+ </header>
457
+
458
+ <div class="toolbar">
459
+ <input type="search" id="search" placeholder="filtrar por nome ou conteúdo…" autocomplete="off">
460
+ <fieldset class="filters" id="type-filters" aria-label="Tipos de evento">
461
+ <!-- populated from EVENT_TYPES at runtime -->
462
+ </fieldset>
463
+ <button id="pause-btn" aria-pressed="false">⏸ pausar</button>
464
+ <button id="autoscroll-btn" aria-pressed="true">↧ rolagem auto</button>
465
+ <button id="clear-btn" title="Limpa o que está visível (histórico no servidor preservado)">limpar tela</button>
466
+ </div>
467
+
468
+ <main>
469
+ <div class="banner" id="shutdown-banner" hidden>
470
+ <strong>Sidecar encerrou.</strong> Recarregue depois de <code>kit ui start</code>.
471
+ </div>
472
+
473
+ <section class="runs" id="active-runs" hidden aria-label="Em execução agora">
474
+ <div class="runs-header">
475
+ <span>Em execução agora</span>
476
+ <span class="count" id="active-runs-count">0</span>
477
+ </div>
478
+ <div id="active-runs-list"></div>
479
+ </section>
480
+
481
+ <ul class="ev-list" id="events" hidden></ul>
482
+ <div class="empty" id="empty">
483
+ <strong>Aguardando primeiro evento…</strong>
484
+ Rode <code>kit sync install</code>, <code>kit reverse-sync apply</code>, ou
485
+ chame uma ferramenta MCP com <code>autoSpawn: true</code> em outra janela.
486
+ </div>
487
+ </main>
488
+
489
+ <div class="footer">
490
+ <span id="footer-events">eventos: 0</span>
491
+ <span id="footer-paused" hidden>pausado: 0 em fila</span>
492
+ <span id="footer-source">fonte: ao vivo</span>
493
+ </div>
494
+
495
+ <script>
496
+ // src/ui/static/index.html — vanilla DOM client for the kit-mcp sidecar.
497
+ // Connects to /events (SSE) and hydrates from /state on load.
498
+ // No build step. No deps.
499
+
500
+ 'use strict';
501
+
502
+ const EVENT_TYPES = ['run.start', 'run.end', 'tool_invocation', 'progress', 'milestone', 'error', 'shutdown'];
503
+ const RING_DISPLAY_MAX = 500;
504
+
505
+ // ------- Humanization (PT-BR friendly labels) ------------------------
506
+ // Maps raw technical event types and tool ids to short human-readable
507
+ // labels. The technical values stay in the data attributes (so CSS colors
508
+ // and filters still key off the raw type) — only the display text changes.
509
+
510
+ const EVENT_TYPE_LABEL = {
511
+ 'run.start': 'Iniciado',
512
+ 'run.end': 'Finalizado',
513
+ 'tool_invocation': 'Comando',
514
+ 'progress': 'Em andamento',
515
+ 'milestone': 'Marco',
516
+ 'error': 'Erro',
517
+ 'shutdown': 'Desligado',
518
+ };
519
+
520
+ const TOOL_LABEL = {
521
+ 'sync.install': 'Sincronizando kit',
522
+ 'sync.watch': 'Vigiando kit (watch)',
523
+ 'reverse-sync.apply': 'Importando edições do IDE',
524
+ 'gates.run': 'Executando gate',
525
+ 'sidecar': 'Servidor sidecar',
526
+ };
527
+
528
+ const STATUS_LABEL = {
529
+ running: 'em execução',
530
+ done: 'concluído',
531
+ error: 'erro',
532
+ };
533
+
534
+ const CONN_LABEL = {
535
+ CONNECTING: 'CONECTANDO',
536
+ OPEN: 'CONECTADO',
537
+ CLOSED: 'DESCONECTADO',
538
+ };
539
+
540
+ const PATH_VERB = {
541
+ reading: 'lendo',
542
+ writing: 'criando',
543
+ projecting: 'projetando',
544
+ merging: 'mesclando',
545
+ copying: 'copiando',
546
+ deleting: 'removendo',
547
+ creating: 'criando',
548
+ updating: 'atualizando',
549
+ syncing: 'sincronizando',
550
+ 'sync': 'sincronizando',
551
+ applying: 'aplicando',
552
+ fetching: 'buscando',
553
+ };
554
+
555
+ function humanizeEventType(t) { return EVENT_TYPE_LABEL[t] || t; }
556
+ function humanizeTool(t) { return TOOL_LABEL[t] || t; }
557
+ function humanizeStatus(s) { return STATUS_LABEL[s] || s; }
558
+
559
+ // Turn a path / file reference inside a label into a friendlier description.
560
+ // Examples:
561
+ // .claude/agents/planner.md → agente planner
562
+ // kit/commands/novo-marco.md → comando novo-marco
563
+ // kit/skills/limpeza/SKILL.md → skill limpeza
564
+ // .claude/framework/templates/codebase/x.md → template framework
565
+ // CLAUDE.md → manifesto CLAUDE.md
566
+ function humanizePath(p) {
567
+ if (typeof p !== 'string' || !p.length) return '';
568
+ const norm = p.replace(/\\/g, '/');
569
+
570
+ let m;
571
+ if ((m = norm.match(/(?:\.claude|kit)\/agents\/([^/]+)\.md$/))) return `agente ${m[1]}`;
572
+ if ((m = norm.match(/(?:\.claude|kit)\/commands\/([^/]+)\.md$/))) return `comando ${m[1]}`;
573
+ if ((m = norm.match(/(?:\.claude|kit)\/skills\/([^/]+)/))) return `skill ${m[1]}`;
574
+ if ((m = norm.match(/(?:\.claude|kit)\/framework\//))) return 'framework';
575
+ if ((m = norm.match(/(?:\.claude|kit)\/hooks\//))) return 'hooks';
576
+ if (norm === 'CLAUDE.md' || norm.endsWith('/CLAUDE.md')) return 'manifesto CLAUDE.md';
577
+ if ((m = norm.match(/(?:\.claude|kit)\/([^/]+)\/([^/]+\.md)$/))) return `${m[1]} ${m[2].replace(/\.md$/, '')}`;
578
+ return norm; // fall back to the raw path if no rule matched
579
+ }
580
+
581
+ // Translate a raw progress label like "writing .claude/agents/planner.md"
582
+ // into "criando agente planner". The function is best-effort: if no rule
583
+ // matches, returns the original label so we never lose information.
584
+ function humanizeLabel(raw) {
585
+ if (typeof raw !== 'string' || !raw.length) return raw;
586
+
587
+ // verb + path (single token after verb)
588
+ const verbMatch = raw.match(/^(reading|writing|projecting|merging|copying|deleting|creating|updating|syncing|sync|applying|fetching)\s+(.+?)(?:\s+\(.+\))?$/i);
589
+ if (verbMatch) {
590
+ const verb = PATH_VERB[verbMatch[1].toLowerCase()] || verbMatch[1];
591
+ const target = humanizePath(verbMatch[2].trim());
592
+ return `${verb} ${target}`;
593
+ }
594
+
595
+ // bare path
596
+ if (/^[^\s]+\.md$/.test(raw) || /^(?:\.claude|kit)\//.test(raw)) {
597
+ return humanizePath(raw);
598
+ }
599
+
600
+ // pure technical action, no path: leave as-is
601
+ return raw;
602
+ }
603
+
604
+ const $ = (id) => document.getElementById(id);
605
+ const dom = {
606
+ conn: $('conn'),
607
+ connText: $('conn-text'),
608
+ metaPort: $('meta-port'),
609
+ list: $('events'),
610
+ empty: $('empty'),
611
+ banner: $('shutdown-banner'),
612
+ pauseBtn: $('pause-btn'),
613
+ autoscrollBtn: $('autoscroll-btn'),
614
+ clearBtn: $('clear-btn'),
615
+ search: $('search'),
616
+ typeFilters: $('type-filters'),
617
+ footerEvents: $('footer-events'),
618
+ footerPaused: $('footer-paused'),
619
+ footerSource: $('footer-source'),
620
+ activeRuns: $('active-runs'),
621
+ activeRunsList: $('active-runs-list'),
622
+ activeRunsCount: $('active-runs-count'),
623
+ };
624
+
625
+ const state = {
626
+ events: [], // currently rendered events (buffered while paused)
627
+ pausedBuffer: [], // events captured while paused
628
+ paused: false,
629
+ autoscroll: true,
630
+ typeFilter: new Set(EVENT_TYPES), // all enabled
631
+ search: '',
632
+ closedAt: null,
633
+ };
634
+
635
+ // ------- DOM helpers --------------------------------------------------
636
+
637
+ function el(tag, props = {}, kids = []) {
638
+ const e = document.createElement(tag);
639
+ for (const [k, v] of Object.entries(props)) {
640
+ if (k === 'class') e.className = v;
641
+ else if (k === 'data') for (const [dk, dv] of Object.entries(v)) e.dataset[dk] = dv;
642
+ else if (k.startsWith('on')) e.addEventListener(k.slice(2).toLowerCase(), v);
643
+ else if (k === 'text') e.textContent = v;
644
+ else if (v !== undefined && v !== null) e.setAttribute(k, v);
645
+ }
646
+ for (const k of kids) if (k) e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k);
647
+ return e;
648
+ }
649
+
650
+ function fmtTime(ts) {
651
+ const d = new Date(ts);
652
+ const hh = String(d.getHours()).padStart(2, '0');
653
+ const mm = String(d.getMinutes()).padStart(2, '0');
654
+ const ss = String(d.getSeconds()).padStart(2, '0');
655
+ return `${hh}:${mm}:${ss}`;
656
+ }
657
+
658
+ function eventLabel(evt) {
659
+ // Best-effort short label from payload; humanizes paths and tool ids;
660
+ // falls back to the humanized event type when payload has nothing useful.
661
+ const p = evt.payload;
662
+ if (!p || typeof p !== 'object') return humanizeEventType(evt.type);
663
+ if (typeof p.label === 'string') return humanizeLabel(p.label);
664
+ if (typeof p.name === 'string') return p.name;
665
+ if (typeof p.tool === 'string') return humanizeTool(p.tool);
666
+ if (typeof p.percent === 'number') return `${p.percent}%${p.kind ? ' · ' + p.kind : ''}`;
667
+ if (typeof p.message === 'string') return p.message;
668
+ if (typeof p.reason === 'string') return p.reason;
669
+ return humanizeEventType(evt.type);
670
+ }
671
+
672
+ function eventMeta(evt) {
673
+ const p = evt.payload;
674
+ if (evt.type === 'progress' && p && typeof p.total === 'number' && typeof p.current === 'number') {
675
+ return `${p.current}/${p.total}`;
676
+ }
677
+ if (evt.runId) return evt.runId.slice(0, 6);
678
+ return '';
679
+ }
680
+
681
+ function renderEventRow(evt) {
682
+ const time = el('div', { class: 'ev-time', text: fmtTime(evt.ts) });
683
+ // data-type stays raw so CSS color rules continue to apply; display text is humanized.
684
+ const badge = el('span', { class: 'ev-badge', data: { type: evt.type }, text: humanizeEventType(evt.type) });
685
+ const summary = el('summary', {}, [
686
+ el('span', { class: 'label', text: eventLabel(evt) }),
687
+ el('span', { class: 'meta', text: eventMeta(evt) }),
688
+ ]);
689
+ const pre = el('pre', { text: JSON.stringify(evt.payload ?? null, null, 2) });
690
+ const details = el('details', {}, [summary, pre]);
691
+ const body = el('div', { class: 'ev-body' }, [details]);
692
+ return el('li', { class: 'ev-row', data: { type: evt.type, label: eventLabel(evt).toLowerCase() } }, [time, badge, body]);
693
+ }
694
+
695
+ function shouldShow(evt) {
696
+ if (!state.typeFilter.has(evt.type)) return false;
697
+ if (state.search) {
698
+ const haystack = (evt.type + ' ' + eventLabel(evt) + ' ' + JSON.stringify(evt.payload ?? '')).toLowerCase();
699
+ if (!haystack.includes(state.search.toLowerCase())) return false;
700
+ }
701
+ return true;
702
+ }
703
+
704
+ function applyFilter() {
705
+ const rows = dom.list.children;
706
+ let visible = 0;
707
+ for (let i = 0; i < rows.length; i += 1) {
708
+ const row = rows[i];
709
+ const evt = state.events[i];
710
+ const show = evt && shouldShow(evt);
711
+ row.style.display = show ? '' : 'none';
712
+ if (show) visible += 1;
713
+ }
714
+ dom.empty.hidden = visible > 0 || state.events.length > 0;
715
+ dom.list.hidden = visible === 0 && state.events.length > 0 ? false : visible === 0;
716
+ if (state.events.length === 0) {
717
+ dom.empty.hidden = false;
718
+ dom.list.hidden = true;
719
+ } else {
720
+ dom.empty.hidden = true;
721
+ dom.list.hidden = false;
722
+ }
723
+ dom.footerEvents.textContent = `eventos: ${state.events.length}` + (visible !== state.events.length ? ` (mostrando ${visible})` : '');
724
+ }
725
+
726
+ function pushVisibleEvent(evt) {
727
+ state.events.push(evt);
728
+ while (state.events.length > RING_DISPLAY_MAX) state.events.shift();
729
+ while (dom.list.children.length > RING_DISPLAY_MAX - 1) dom.list.removeChild(dom.list.firstChild);
730
+ const row = renderEventRow(evt);
731
+ dom.list.appendChild(row);
732
+ if (!shouldShow(evt)) row.style.display = 'none';
733
+ applyFilter();
734
+ if (state.autoscroll) {
735
+ requestAnimationFrame(() => row.scrollIntoView({ block: 'end' }));
736
+ }
737
+ }
738
+
739
+ function ingestEvent(evt) {
740
+ // Always update the active-runs panel — that view is the live "what's
741
+ // happening NOW" and shouldn't be affected by the pause toggle below.
742
+ upsertActiveRun(evt);
743
+
744
+ if (state.paused) {
745
+ state.pausedBuffer.push(evt);
746
+ dom.footerPaused.hidden = false;
747
+ dom.footerPaused.textContent = `pausado: ${state.pausedBuffer.length} em fila`;
748
+ return;
749
+ }
750
+ pushVisibleEvent(evt);
751
+ if (evt.type === 'shutdown') {
752
+ dom.banner.hidden = false;
753
+ }
754
+ }
755
+
756
+ // ------- Active runs ---------------------------------------------------
757
+ // Maintain a Map<runId, ActiveRun> that tracks currently-executing tools.
758
+ // Updated from run.start / progress / run.end / error events that share a
759
+ // runId. Renders as cards above the event log so the user sees CURRENT
760
+ // state at a glance instead of having to scan the chronological feed.
761
+
762
+ const activeRuns = new Map(); // runId -> {tool, label, percent, current, total, startedAt, status, pendingRemove}
763
+ const fadeTimers = new Map(); // runId -> setTimeout handle
764
+
765
+ function upsertActiveRun(evt) {
766
+ if (!evt.runId) return;
767
+ const id = evt.runId;
768
+ const p = evt.payload || {};
769
+ let run = activeRuns.get(id);
770
+
771
+ if (evt.type === 'run.start') {
772
+ // Cancel any pending fade-out for a stale entry with the same id (rare).
773
+ if (fadeTimers.has(id)) { clearTimeout(fadeTimers.get(id)); fadeTimers.delete(id); }
774
+ activeRuns.set(id, {
775
+ runId: id,
776
+ tool: p.tool || p.server || 'run',
777
+ label: 'iniciando…',
778
+ percent: 0,
779
+ current: null,
780
+ total: null,
781
+ startedAt: evt.ts,
782
+ status: 'running',
783
+ });
784
+ } else if (evt.type === 'progress' && run && run.status === 'running') {
785
+ if (typeof p.percent === 'number') run.percent = clampPercent(p.percent);
786
+ if (typeof p.current === 'number') run.current = p.current;
787
+ if (typeof p.total === 'number') run.total = p.total;
788
+ if (typeof p.label === 'string' && p.label.length > 0) run.label = humanizeLabel(p.label);
789
+ // If the wrapper sent current/total but no percent, derive it.
790
+ if (typeof p.percent !== 'number' && typeof p.current === 'number' && typeof p.total === 'number' && p.total > 0) {
791
+ run.percent = clampPercent(Math.round((p.current / p.total) * 100));
792
+ }
793
+ } else if (evt.type === 'tool_invocation' && run && run.status === 'running') {
794
+ // tool_invocation events refine the title if it arrived after run.start.
795
+ if (typeof p.tool === 'string') run.tool = p.tool;
796
+ } else if (evt.type === 'run.end' && run) {
797
+ run.status = (p && p.ok === false) ? 'error' : 'done';
798
+ run.percent = run.status === 'done' ? 100 : run.percent;
799
+ run.label = run.status === 'done' ? 'concluído com sucesso' : (p?.message || 'falhou');
800
+ // Fade the card out a few seconds after completion so the user sees
801
+ // the 100% and "done" before it disappears.
802
+ const t = setTimeout(() => {
803
+ activeRuns.delete(id);
804
+ fadeTimers.delete(id);
805
+ renderActiveRuns();
806
+ }, 4000);
807
+ fadeTimers.set(id, t);
808
+ } else if (evt.type === 'error' && run && run.status === 'running') {
809
+ run.status = 'error';
810
+ run.label = p?.message || 'error';
811
+ // Errors stay visible longer (8s) so the user has time to read.
812
+ const t = setTimeout(() => {
813
+ activeRuns.delete(id);
814
+ fadeTimers.delete(id);
815
+ renderActiveRuns();
816
+ }, 8000);
817
+ fadeTimers.set(id, t);
818
+ }
819
+ renderActiveRuns();
820
+ }
821
+
822
+ function clampPercent(n) {
823
+ if (!Number.isFinite(n)) return 0;
824
+ return Math.max(0, Math.min(100, n));
825
+ }
826
+
827
+ function fmtElapsed(startTs) {
828
+ const sec = Math.max(0, Math.round((Date.now() - startTs) / 1000));
829
+ if (sec < 60) return `${sec}s`;
830
+ const m = Math.floor(sec / 60);
831
+ const s = sec % 60;
832
+ return `${m}m ${s.toString().padStart(2, '0')}s`;
833
+ }
834
+
835
+ function renderActiveRuns() {
836
+ const runs = [...activeRuns.values()].sort((a, b) => a.startedAt - b.startedAt);
837
+ if (runs.length === 0) {
838
+ dom.activeRuns.hidden = true;
839
+ dom.activeRunsList.replaceChildren();
840
+ return;
841
+ }
842
+ dom.activeRuns.hidden = false;
843
+ dom.activeRunsCount.textContent = String(runs.length);
844
+
845
+ // Use a stable key (runId) so we update existing cards in place rather than
846
+ // recreating them (preserves the CSS transition on the progress bar width).
847
+ const existing = new Map();
848
+ for (const child of dom.activeRunsList.children) {
849
+ existing.set(child.dataset.runid, child);
850
+ }
851
+
852
+ const frag = document.createDocumentFragment();
853
+ for (const run of runs) {
854
+ let card = existing.get(run.runId);
855
+ if (!card) {
856
+ card = el('div', { class: 'run-card', data: { runid: run.runId } });
857
+ card.innerHTML = `
858
+ <div class="run-title">
859
+ <span class="run-name"></span>
860
+ <span class="run-kind"></span>
861
+ </div>
862
+ <div class="run-percent"></div>
863
+ <div class="run-bar-wrap"><div class="run-bar"></div></div>
864
+ <div class="run-step"></div>
865
+ <div class="run-meta"><span class="elapsed"></span><span class="runid"></span></div>
866
+ `;
867
+ }
868
+ card.dataset.status = run.status;
869
+ card.querySelector('.run-name').textContent = humanizeTool(run.tool);
870
+ card.querySelector('.run-kind').textContent = humanizeStatus(run.status);
871
+ card.querySelector('.run-percent').textContent = run.status === 'error' ? 'ERRO' : `${run.percent}%`;
872
+ card.querySelector('.run-bar').style.width = `${run.percent}%`;
873
+ card.querySelector('.run-step').textContent = run.label;
874
+ const elapsed = card.querySelector('.elapsed');
875
+ elapsed.textContent = `há ${fmtElapsed(run.startedAt)}`;
876
+ const ridEl = card.querySelector('.runid');
877
+ const progressTotalText = (run.current !== null && run.total !== null) ? ` · ${run.current}/${run.total}` : '';
878
+ ridEl.textContent = `id ${run.runId.slice(0, 8)}${progressTotalText}`;
879
+ frag.appendChild(card);
880
+ }
881
+ dom.activeRunsList.replaceChildren(frag);
882
+ }
883
+
884
+ // Tick the elapsed-time labels every second so they stay live.
885
+ setInterval(() => {
886
+ if (activeRuns.size === 0) return;
887
+ for (const card of dom.activeRunsList.children) {
888
+ const id = card.dataset.runid;
889
+ const run = activeRuns.get(id);
890
+ if (run) card.querySelector('.elapsed').textContent = `há ${fmtElapsed(run.startedAt)}`;
891
+ }
892
+ }, 1000);
893
+
894
+ function flushPaused() {
895
+ for (const evt of state.pausedBuffer) pushVisibleEvent(evt);
896
+ state.pausedBuffer.length = 0;
897
+ dom.footerPaused.hidden = true;
898
+ }
899
+
900
+ // ------- Filter UI ----------------------------------------------------
901
+
902
+ for (const t of EVENT_TYPES) {
903
+ const cb = el('input', { type: 'checkbox', checked: '' });
904
+ cb.checked = true;
905
+ cb.addEventListener('change', () => {
906
+ if (cb.checked) state.typeFilter.add(t);
907
+ else state.typeFilter.delete(t);
908
+ applyFilter();
909
+ });
910
+ // Show humanized text but keep the data-type as the raw event name so
911
+ // power users (and tests) can still target the underlying value.
912
+ const lbl = el('label', { data: { type: t } }, [cb, document.createTextNode(humanizeEventType(t))]);
913
+ dom.typeFilters.appendChild(lbl);
914
+ }
915
+
916
+ dom.search.addEventListener('input', () => {
917
+ state.search = dom.search.value;
918
+ applyFilter();
919
+ });
920
+
921
+ dom.pauseBtn.addEventListener('click', () => {
922
+ state.paused = !state.paused;
923
+ dom.pauseBtn.setAttribute('aria-pressed', String(state.paused));
924
+ dom.pauseBtn.textContent = state.paused ? '▶ retomar' : '⏸ pausar';
925
+ if (!state.paused) flushPaused();
926
+ });
927
+
928
+ dom.autoscrollBtn.addEventListener('click', () => {
929
+ state.autoscroll = !state.autoscroll;
930
+ dom.autoscrollBtn.setAttribute('aria-pressed', String(state.autoscroll));
931
+ });
932
+
933
+ dom.clearBtn.addEventListener('click', () => {
934
+ state.events.length = 0;
935
+ while (dom.list.firstChild) dom.list.removeChild(dom.list.firstChild);
936
+ applyFilter();
937
+ });
938
+
939
+ // ------- Connection lifecycle ----------------------------------------
940
+
941
+ let evtSource = null;
942
+ let closedTimer = null;
943
+ let lastConnectedAt = 0;
944
+
945
+ function setConnState(s) {
946
+ dom.conn.dataset.state = s;
947
+ dom.connText.textContent = CONN_LABEL[s] || s;
948
+ }
949
+
950
+ function scheduleClosedBanner() {
951
+ if (closedTimer) clearTimeout(closedTimer);
952
+ closedTimer = setTimeout(() => {
953
+ // Only show shutdown banner if still closed after 5s
954
+ if (dom.conn.dataset.state === 'CLOSED') {
955
+ dom.banner.hidden = false;
956
+ }
957
+ }, 5000);
958
+ }
959
+
960
+ async function hydrateFromState() {
961
+ try {
962
+ const res = await fetch('/state', { credentials: 'omit' });
963
+ if (!res.ok) return;
964
+ const j = await res.json();
965
+ if (j.port) dom.metaPort.textContent = `porta ${j.port}`;
966
+ if (Array.isArray(j.events)) {
967
+ for (const evt of j.events) ingestEvent(evt);
968
+ }
969
+ } catch (_) {
970
+ // Ignore — SSE may still work
971
+ }
972
+ }
973
+
974
+ function connect() {
975
+ setConnState('CONNECTING');
976
+ if (evtSource) try { evtSource.close(); } catch (_) { /* noop */ }
977
+ evtSource = new EventSource('/events');
978
+ evtSource.onopen = () => {
979
+ setConnState('OPEN');
980
+ lastConnectedAt = Date.now();
981
+ if (closedTimer) { clearTimeout(closedTimer); closedTimer = null; }
982
+ // Don't hide banner if we already received a 'shutdown' event
983
+ };
984
+ evtSource.onerror = () => {
985
+ setConnState('CLOSED');
986
+ scheduleClosedBanner();
987
+ // EventSource will retry automatically (we send retry: 3000 from server)
988
+ };
989
+ // Listen for each known event type so 'event:' lines get routed to the same handler
990
+ const handler = (msg) => {
991
+ try {
992
+ const data = JSON.parse(msg.data);
993
+ ingestEvent(data);
994
+ } catch (_) { /* swallow malformed */ }
995
+ };
996
+ for (const t of EVENT_TYPES) evtSource.addEventListener(t, handler);
997
+ evtSource.onmessage = handler; // fallback for events without type field
998
+ }
999
+
1000
+ // ------- Background-tab recovery -------------------------------------
1001
+ // Chrome aggressively throttles background tabs; the native EventSource
1002
+ // retry timer can get suspended, leaving the connection stuck in CLOSED
1003
+ // when the user comes back. The Page Visibility API lets us force a fresh
1004
+ // connect() when the tab becomes visible again and we know we're CLOSED.
1005
+ document.addEventListener('visibilitychange', () => {
1006
+ if (document.visibilityState !== 'visible') return;
1007
+ if (dom.conn.dataset.state === 'CLOSED') {
1008
+ // Re-hydrate from /state in case events arrived while we were dropped,
1009
+ // then reopen the SSE stream.
1010
+ hydrateFromState().then(connect);
1011
+ }
1012
+ });
1013
+
1014
+ // ------- Boot ---------------------------------------------------------
1015
+
1016
+ hydrateFromState().then(() => {
1017
+ connect();
1018
+ applyFilter();
1019
+ });
1020
+ </script>
1021
+ </body>
1022
+ </html>