otterly 0.3.1 → 0.3.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.
@@ -1,540 +1,795 @@
1
1
  // Interactive API playground — self-contained HTML served at GET /playground.
2
- // Zero external dependencies. Dark mode. GitHub-dark palette.
2
+ // Zero external dependencies. Dark mode. Modern dev-tool aesthetic.
3
3
  function playgroundCSS() {
4
4
  return `
5
5
  :root {
6
- --bg: #0d1117;
7
- --surface: #161b22;
8
- --surface-raised: #1c2129;
9
- --border: #30363d;
10
- --text: #e6edf3;
11
- --text-muted: #8b949e;
12
- --accent: #58a6ff;
13
- --accent-hover: #79c0ff;
14
- --success: #3fb950;
15
- --error: #f85149;
16
- --warning: #d29922;
17
- --font-mono: 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace;
18
- --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
19
- --radius: 6px;
20
- }
21
-
22
- * { margin: 0; padding: 0; box-sizing: border-box; }
6
+ --bg-0: #09090b;
7
+ --bg-1: #0f0f12;
8
+ --bg-2: #18181b;
9
+ --bg-3: #232328;
10
+ --border: #27272a;
11
+ --border-subtle: #1e1e22;
12
+ --text-0: #fafafa;
13
+ --text-1: #a1a1aa;
14
+ --text-2: #63636e;
15
+ --accent: #5b9dff;
16
+ --accent-soft: rgba(91, 157, 255, 0.1);
17
+ --accent-border: rgba(91, 157, 255, 0.25);
18
+ --green: #4ade80;
19
+ --green-soft: rgba(74, 222, 128, 0.1);
20
+ --green-border: rgba(74, 222, 128, 0.25);
21
+ --red: #f87171;
22
+ --red-soft: rgba(248, 113, 113, 0.1);
23
+ --amber: #fbbf24;
24
+ --amber-soft: rgba(251, 191, 36, 0.1);
25
+ --font-sans: -apple-system, 'Inter', system-ui, 'Segoe UI', sans-serif;
26
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', 'Cascadia Code', Consolas, monospace;
27
+ --radius: 8px;
28
+ --radius-sm: 6px;
29
+ }
30
+
31
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
23
32
 
24
33
  body {
25
34
  font-family: var(--font-sans);
26
- background: var(--bg);
27
- color: var(--text);
28
- line-height: 1.5;
29
- min-height: 100vh;
35
+ background: var(--bg-0);
36
+ color: var(--text-0);
37
+ height: 100vh;
38
+ overflow: hidden;
39
+ -webkit-font-smoothing: antialiased;
30
40
  }
31
41
 
32
- /* Header */
42
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
43
+ ::-webkit-scrollbar-track { background: transparent; }
44
+ ::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 3px; }
45
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-2); }
46
+
47
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
48
+
49
+ /* ── App layout ── */
50
+ .app {
51
+ display: grid;
52
+ grid-template-rows: 52px 1fr;
53
+ height: 100vh;
54
+ }
55
+
56
+ /* ── Header ── */
33
57
  .header {
34
- display: flex;
58
+ display: grid;
59
+ grid-template-columns: 1fr auto 1fr;
35
60
  align-items: center;
36
- justify-content: space-between;
37
- padding: 12px 20px;
61
+ padding: 0 20px;
62
+ background: var(--bg-1);
38
63
  border-bottom: 1px solid var(--border);
39
- background: var(--surface);
64
+ z-index: 10;
40
65
  }
41
66
  .header-left {
42
67
  display: flex;
43
68
  align-items: center;
44
69
  gap: 10px;
45
- font-size: 16px;
70
+ }
71
+ .logo {
72
+ font-size: 18px;
73
+ line-height: 1;
74
+ }
75
+ .logo-text {
76
+ font-size: 14px;
46
77
  font-weight: 600;
78
+ color: var(--text-0);
79
+ letter-spacing: -0.3px;
80
+ }
81
+ .version-badge {
82
+ font-size: 11px;
83
+ font-family: var(--font-mono);
84
+ color: var(--text-2);
85
+ background: var(--bg-3);
86
+ padding: 2px 7px;
87
+ border-radius: 4px;
88
+ }
89
+ .header-center {
90
+ display: flex;
91
+ justify-content: center;
47
92
  }
48
93
  .header-right {
49
94
  display: flex;
95
+ justify-content: flex-end;
50
96
  align-items: center;
51
- gap: 8px;
52
97
  }
53
- .api-key-input {
54
- background: var(--bg);
55
- border: 1px solid var(--border);
98
+
99
+ /* ── Nav (segmented control) ── */
100
+ .nav {
101
+ display: inline-flex;
102
+ gap: 1px;
103
+ background: var(--bg-2);
56
104
  border-radius: var(--radius);
57
- color: var(--text);
58
- padding: 6px 10px;
105
+ padding: 3px;
106
+ }
107
+ .nav-item {
108
+ padding: 6px 16px;
109
+ border-radius: var(--radius-sm);
59
110
  font-size: 13px;
60
- font-family: var(--font-mono);
61
- width: 220px;
111
+ font-weight: 500;
112
+ color: var(--text-1);
113
+ background: transparent;
114
+ border: none;
115
+ cursor: pointer;
116
+ transition: color 0.15s, background 0.15s;
117
+ font-family: var(--font-sans);
118
+ white-space: nowrap;
119
+ }
120
+ .nav-item:hover { color: var(--text-0); background: var(--bg-3); }
121
+ .nav-item.active {
122
+ color: var(--text-0);
123
+ background: var(--bg-3);
124
+ box-shadow: 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.04);
62
125
  }
63
- .api-key-input:focus { outline: none; border-color: var(--accent); }
64
126
 
65
- /* Tabs */
66
- .tabs {
127
+ /* ── API key input ── */
128
+ .key-wrapper {
67
129
  display: flex;
68
- gap: 0;
69
- border-bottom: 1px solid var(--border);
70
- background: var(--surface);
71
- padding: 0 20px;
72
- overflow-x: auto;
130
+ align-items: center;
131
+ gap: 8px;
132
+ background: var(--bg-2);
133
+ border: 1px solid var(--border);
134
+ border-radius: var(--radius-sm);
135
+ padding: 0 10px;
136
+ height: 32px;
137
+ transition: border-color 0.15s;
73
138
  }
74
- .tab {
75
- padding: 10px 16px;
76
- cursor: pointer;
77
- color: var(--text-muted);
78
- font-size: 13px;
79
- font-weight: 500;
80
- border-bottom: 2px solid transparent;
81
- transition: color 0.15s, border-color 0.15s;
82
- white-space: nowrap;
139
+ .key-wrapper:focus-within { border-color: var(--accent); }
140
+ .key-wrapper svg { flex-shrink: 0; color: var(--text-2); }
141
+ .key-input {
83
142
  background: none;
84
- border-top: none;
85
- border-left: none;
86
- border-right: none;
87
- font-family: var(--font-sans);
143
+ border: none;
144
+ color: var(--text-0);
145
+ font-size: 12px;
146
+ font-family: var(--font-mono);
147
+ width: 160px;
148
+ outline: none;
88
149
  }
89
- .tab:hover { color: var(--text); }
90
- .tab.active {
91
- color: var(--accent);
92
- border-bottom-color: var(--accent);
150
+ .key-input::placeholder { color: var(--text-2); }
151
+ .key-input:focus { outline: none; }
152
+
153
+ /* ── Content ── */
154
+ .content {
155
+ overflow: hidden;
156
+ position: relative;
93
157
  }
158
+ .panel { display: none; height: 100%; }
159
+ .panel.active { display: flex; }
94
160
 
95
- /* Main */
96
- .main {
97
- max-width: 960px;
98
- margin: 0 auto;
99
- padding: 20px;
161
+ /* ── Split pane ── */
162
+ .split-pane {
163
+ display: grid;
164
+ grid-template-columns: 1fr 1fr;
165
+ width: 100%;
166
+ height: 100%;
167
+ }
168
+ .split-pane > .pane {
169
+ display: flex;
170
+ flex-direction: column;
171
+ overflow: hidden;
172
+ }
173
+ .split-pane > .pane:first-child {
174
+ border-right: 1px solid var(--border);
175
+ }
176
+ .pane-head {
177
+ display: flex;
178
+ align-items: center;
179
+ gap: 10px;
180
+ padding: 12px 20px;
181
+ border-bottom: 1px solid var(--border-subtle);
182
+ flex-shrink: 0;
183
+ min-height: 44px;
184
+ }
185
+ .pane-head-right {
186
+ margin-left: auto;
187
+ display: flex;
188
+ align-items: center;
189
+ gap: 6px;
190
+ }
191
+ .pane-body {
192
+ flex: 1;
193
+ overflow-y: auto;
194
+ padding: 16px 20px;
195
+ display: flex;
196
+ flex-direction: column;
100
197
  }
101
- .panel { display: none; }
102
- .panel.active { display: block; }
103
198
 
104
- /* Request panel */
105
- .endpoint {
199
+ /* ── Method badges ── */
200
+ .method-badge {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ padding: 3px 8px;
204
+ border-radius: 4px;
205
+ font-size: 11px;
206
+ font-weight: 700;
106
207
  font-family: var(--font-mono);
107
- font-size: 14px;
108
- color: var(--accent);
109
- margin-bottom: 16px;
208
+ letter-spacing: 0.5px;
110
209
  }
111
- .endpoint .method {
112
- background: var(--accent);
113
- color: var(--bg);
114
- padding: 2px 8px;
115
- border-radius: 3px;
116
- font-weight: 700;
117
- font-size: 12px;
118
- margin-right: 8px;
210
+ .method-get { color: var(--green); background: var(--green-soft); }
211
+ .method-post { color: var(--accent); background: var(--accent-soft); }
212
+ .method-ws { color: var(--amber); background: var(--amber-soft); }
213
+ .endpoint-path {
214
+ font-family: var(--font-mono);
215
+ font-size: 13px;
216
+ color: var(--text-1);
119
217
  }
120
218
 
121
- .section-header {
122
- font-size: 12px;
219
+ /* ── Form elements ── */
220
+ .field-label {
221
+ font-size: 11px;
123
222
  font-weight: 600;
124
223
  text-transform: uppercase;
125
224
  letter-spacing: 0.5px;
126
- color: var(--text-muted);
127
- margin-bottom: 8px;
128
- cursor: pointer;
129
- user-select: none;
225
+ color: var(--text-2);
226
+ margin-bottom: 6px;
130
227
  }
131
- .section-header::before {
132
- content: '\\25B6';
133
- display: inline-block;
134
- margin-right: 6px;
135
- font-size: 10px;
136
- transition: transform 0.15s;
137
- }
138
- .section-header.open::before {
139
- transform: rotate(90deg);
140
- }
141
-
142
- .collapsible { display: none; margin-bottom: 16px; }
143
- .collapsible.open { display: block; }
144
-
145
- .header-row {
228
+ .field-row {
146
229
  display: flex;
230
+ align-items: center;
147
231
  gap: 8px;
148
232
  margin-bottom: 8px;
149
233
  }
150
- .header-row input {
151
- flex: 1;
152
- background: var(--bg);
153
- border: 1px solid var(--border);
154
- border-radius: var(--radius);
155
- color: var(--text);
156
- padding: 6px 10px;
157
- font-size: 13px;
234
+ .field-row .label-inline {
235
+ font-size: 12px;
158
236
  font-family: var(--font-mono);
237
+ color: var(--text-2);
238
+ min-width: 90px;
159
239
  }
160
- .header-row input:focus { outline: none; border-color: var(--accent); }
161
- .header-row .label {
162
- min-width: 110px;
240
+ input[type="text"] {
241
+ flex: 1;
242
+ background: var(--bg-0);
243
+ border: 1px solid var(--border);
244
+ border-radius: var(--radius-sm);
245
+ color: var(--text-0);
246
+ padding: 7px 10px;
163
247
  font-size: 12px;
164
- color: var(--text-muted);
165
- display: flex;
166
- align-items: center;
248
+ font-family: var(--font-mono);
249
+ transition: border-color 0.15s;
167
250
  }
251
+ input[type="text"]:focus { outline: none; border-color: var(--accent); }
252
+ input[type="text"]::placeholder { color: var(--text-2); }
168
253
 
169
254
  textarea {
170
- width: 100%;
171
- min-height: 200px;
172
- background: var(--bg);
255
+ flex: 1;
256
+ min-height: 120px;
257
+ background: var(--bg-0);
173
258
  border: 1px solid var(--border);
174
- border-radius: var(--radius);
175
- color: var(--text);
176
- padding: 12px;
259
+ border-radius: var(--radius-sm);
260
+ color: var(--text-0);
261
+ padding: 12px 14px;
177
262
  font-size: 13px;
178
263
  font-family: var(--font-mono);
179
- line-height: 1.5;
180
- resize: vertical;
264
+ line-height: 1.6;
265
+ resize: none;
181
266
  tab-size: 2;
267
+ transition: border-color 0.15s;
182
268
  }
183
269
  textarea:focus { outline: none; border-color: var(--accent); }
270
+ textarea::placeholder { color: var(--text-2); }
184
271
 
185
- .btn-row {
272
+ /* ── Collapsible sections ── */
273
+ .section-toggle {
274
+ display: flex;
275
+ align-items: center;
276
+ gap: 6px;
277
+ font-size: 11px;
278
+ font-weight: 600;
279
+ text-transform: uppercase;
280
+ letter-spacing: 0.5px;
281
+ color: var(--text-2);
282
+ cursor: pointer;
283
+ user-select: none;
284
+ padding: 6px 0;
285
+ margin-bottom: 4px;
286
+ background: none;
287
+ border: none;
288
+ font-family: var(--font-sans);
289
+ }
290
+ .section-toggle:hover { color: var(--text-1); }
291
+ .section-toggle svg {
292
+ transition: transform 0.15s;
293
+ }
294
+ .section-toggle.open svg {
295
+ transform: rotate(90deg);
296
+ }
297
+ .collapsible { display: none; margin-bottom: 12px; }
298
+ .collapsible.open { display: block; }
299
+
300
+ /* ── Buttons ── */
301
+ .actions {
186
302
  display: flex;
187
303
  gap: 8px;
188
304
  margin-top: 12px;
305
+ flex-shrink: 0;
189
306
  }
190
307
  .btn {
191
- padding: 8px 20px;
308
+ display: inline-flex;
309
+ align-items: center;
310
+ justify-content: center;
311
+ gap: 6px;
312
+ height: 32px;
313
+ padding: 0 14px;
192
314
  border: none;
193
- border-radius: var(--radius);
315
+ border-radius: var(--radius-sm);
194
316
  font-size: 13px;
195
- font-weight: 600;
317
+ font-weight: 500;
196
318
  cursor: pointer;
197
319
  font-family: var(--font-sans);
198
- transition: opacity 0.15s;
320
+ transition: all 0.15s;
199
321
  }
200
- .btn:hover { opacity: 0.85; }
201
- .btn:disabled { opacity: 0.5; cursor: not-allowed; }
322
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; }
202
323
  .btn-primary {
203
324
  background: var(--accent);
204
- color: var(--bg);
325
+ color: #fff;
205
326
  }
206
- .btn-secondary {
207
- background: var(--surface-raised);
208
- color: var(--text);
327
+ .btn-primary:hover:not(:disabled) { background: #4a8df0; }
328
+ .btn-ghost {
329
+ background: transparent;
330
+ color: var(--text-1);
209
331
  border: 1px solid var(--border);
210
332
  }
333
+ .btn-ghost:hover:not(:disabled) { background: var(--bg-2); color: var(--text-0); }
334
+ .btn-sm {
335
+ height: 26px;
336
+ padding: 0 10px;
337
+ font-size: 12px;
338
+ }
211
339
 
212
- /* Response */
213
- .response-bar {
340
+ /* ── Response area ── */
341
+ .res-status {
214
342
  display: flex;
215
343
  align-items: center;
216
- gap: 12px;
217
- margin-top: 24px;
218
- margin-bottom: 8px;
344
+ gap: 10px;
219
345
  font-size: 13px;
220
346
  }
221
- .response-bar .status-badge {
347
+ .status-pill {
348
+ display: inline-flex;
349
+ align-items: center;
222
350
  padding: 2px 8px;
223
- border-radius: 3px;
224
- font-weight: 600;
351
+ border-radius: 4px;
225
352
  font-size: 12px;
353
+ font-weight: 600;
226
354
  font-family: var(--font-mono);
227
355
  }
228
- .response-bar .status-badge.ok { background: var(--success); color: var(--bg); }
229
- .response-bar .status-badge.err { background: var(--error); color: #fff; }
230
- .response-bar .duration { color: var(--text-muted); }
231
-
232
- .response-tabs {
233
- display: flex;
234
- gap: 0;
235
- margin-bottom: 0;
356
+ .status-pill.ok { color: var(--green); background: var(--green-soft); }
357
+ .status-pill.err { color: var(--red); background: var(--red-soft); }
358
+ .status-pill.pending { color: var(--amber); background: var(--amber-soft); }
359
+ .status-pill.cancelled { color: var(--text-2); background: var(--bg-3); }
360
+ .res-duration { color: var(--text-2); font-size: 12px; font-family: var(--font-mono); }
361
+
362
+ .mode-toggle {
363
+ display: inline-flex;
364
+ background: var(--bg-2);
365
+ border-radius: 5px;
366
+ padding: 2px;
367
+ gap: 1px;
236
368
  }
237
- .response-tab {
238
- padding: 6px 14px;
369
+ .mode-toggle button {
370
+ padding: 3px 10px;
371
+ border-radius: 4px;
372
+ font-size: 11px;
373
+ font-weight: 500;
374
+ color: var(--text-2);
375
+ background: transparent;
376
+ border: none;
239
377
  cursor: pointer;
240
- font-size: 12px;
241
- color: var(--text-muted);
242
- background: var(--surface);
243
- border: 1px solid var(--border);
244
- border-bottom: none;
245
- border-radius: var(--radius) var(--radius) 0 0;
246
378
  font-family: var(--font-sans);
379
+ transition: all 0.15s;
247
380
  }
248
- .response-tab.active {
249
- color: var(--text);
250
- background: var(--bg);
251
- }
381
+ .mode-toggle button.active { color: var(--text-0); background: var(--bg-3); }
382
+ .mode-toggle button:hover:not(.active) { color: var(--text-1); }
252
383
 
253
- .response-body {
254
- background: var(--bg);
255
- border: 1px solid var(--border);
256
- border-radius: 0 var(--radius) var(--radius) var(--radius);
257
- padding: 16px;
384
+ .code-output {
385
+ flex: 1;
386
+ overflow-y: auto;
387
+ background: var(--bg-0);
388
+ border: 1px solid var(--border-subtle);
389
+ border-radius: var(--radius-sm);
390
+ padding: 14px 16px;
258
391
  font-family: var(--font-mono);
259
392
  font-size: 13px;
393
+ line-height: 1.6;
260
394
  white-space: pre-wrap;
261
395
  word-break: break-word;
262
- max-height: 500px;
263
- overflow-y: auto;
264
- position: relative;
396
+ margin-top: 12px;
397
+ }
398
+ .code-output.empty-state {
399
+ display: flex;
400
+ align-items: center;
401
+ justify-content: center;
402
+ color: var(--text-2);
403
+ font-family: var(--font-sans);
404
+ font-size: 13px;
265
405
  }
266
406
 
267
- /* Streaming */
268
- .stream-output {
269
- background: var(--bg);
270
- border: 1px solid var(--border);
271
- border-radius: var(--radius);
272
- padding: 16px;
407
+ /* ── JSON syntax highlighting ── */
408
+ .json-key { color: #c4c4cc; }
409
+ .json-string { color: #7dd3a8; }
410
+ .json-number { color: #8cb4ff; }
411
+ .json-boolean { color: #fbbf24; }
412
+ .json-null { color: #63636e; }
413
+
414
+ /* ── Streaming ── */
415
+ .stream-area {
416
+ flex: 1;
417
+ overflow-y: auto;
418
+ background: var(--bg-0);
419
+ border: 1px solid var(--border-subtle);
420
+ border-radius: var(--radius-sm);
421
+ padding: 14px 16px;
273
422
  font-family: var(--font-mono);
274
423
  font-size: 13px;
424
+ line-height: 1.6;
275
425
  white-space: pre-wrap;
276
426
  word-break: break-word;
277
- max-height: 500px;
278
- overflow-y: auto;
279
- margin-top: 16px;
427
+ margin-top: 12px;
280
428
  position: relative;
281
429
  }
282
-
283
430
  .cursor-blink {
284
431
  display: inline-block;
285
- width: 8px;
286
- height: 16px;
432
+ width: 2px;
433
+ height: 15px;
287
434
  background: var(--accent);
288
435
  animation: blink 1s step-end infinite;
289
436
  vertical-align: text-bottom;
437
+ margin-left: 1px;
438
+ border-radius: 1px;
290
439
  }
291
- @keyframes blink {
292
- 50% { opacity: 0; }
293
- }
440
+ @keyframes blink { 50% { opacity: 0; } }
294
441
 
295
- .tool-call-block {
296
- border-left: 3px solid var(--warning);
442
+ .tool-block {
443
+ border-left: 2px solid var(--amber);
297
444
  margin: 8px 0;
298
445
  padding: 8px 12px;
299
- background: var(--surface);
300
- border-radius: 0 var(--radius) var(--radius) 0;
446
+ background: var(--bg-2);
447
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
301
448
  }
302
- .tool-call-header {
449
+ .tool-block-head {
303
450
  font-weight: 600;
304
451
  font-size: 12px;
305
- color: var(--warning);
452
+ color: var(--amber);
306
453
  cursor: pointer;
307
454
  user-select: none;
455
+ display: flex;
456
+ align-items: center;
457
+ gap: 6px;
308
458
  }
309
- .tool-call-header::before {
310
- content: '\\25B6 ';
311
- font-size: 10px;
312
- }
313
- .tool-call-block.open .tool-call-header::before {
314
- content: '\\25BC ';
315
- }
316
- .tool-call-body {
459
+ .tool-block-head svg { transition: transform 0.15s; }
460
+ .tool-block.open .tool-block-head svg { transform: rotate(90deg); }
461
+ .tool-block-body {
317
462
  display: none;
318
463
  margin-top: 6px;
319
464
  font-size: 12px;
320
- color: var(--text-muted);
465
+ color: var(--text-1);
321
466
  }
322
- .tool-call-block.open .tool-call-body { display: block; }
323
-
324
- .tool-result-ok { border-left-color: var(--success); }
325
- .tool-result-err { border-left-color: var(--error); }
467
+ .tool-block.open .tool-block-body { display: block; }
468
+ .tool-block.result-ok { border-left-color: var(--green); }
469
+ .tool-block.result-ok .tool-block-head { color: var(--green); }
470
+ .tool-block.result-err { border-left-color: var(--red); }
471
+ .tool-block.result-err .tool-block-head { color: var(--red); }
326
472
 
327
473
  .summary-bar {
328
474
  display: flex;
329
- gap: 16px;
330
- margin-top: 12px;
331
- padding: 8px 12px;
332
- background: var(--surface);
333
- border-radius: var(--radius);
475
+ gap: 20px;
476
+ padding: 10px 14px;
477
+ background: var(--bg-2);
478
+ border-radius: var(--radius-sm);
334
479
  font-size: 12px;
335
- color: var(--text-muted);
480
+ color: var(--text-1);
481
+ margin-top: 10px;
482
+ flex-shrink: 0;
336
483
  }
337
484
  .summary-bar span { font-family: var(--font-mono); }
485
+ .summary-bar .label { color: var(--text-2); margin-right: 4px; }
338
486
 
339
- .jump-bottom {
487
+ .jump-btn {
340
488
  position: sticky;
341
489
  bottom: 8px;
342
490
  float: right;
343
- padding: 4px 12px;
491
+ padding: 4px 10px;
344
492
  font-size: 11px;
345
- background: var(--accent);
346
- color: var(--bg);
347
- border: none;
348
- border-radius: var(--radius);
493
+ background: var(--bg-3);
494
+ color: var(--text-1);
495
+ border: 1px solid var(--border);
496
+ border-radius: var(--radius-sm);
349
497
  cursor: pointer;
350
498
  display: none;
499
+ font-family: var(--font-sans);
351
500
  }
501
+ .jump-btn:hover { background: var(--bg-2); }
352
502
 
353
- /* Status dashboard */
354
- .status-grid {
355
- display: grid;
356
- grid-template-columns: 1fr 1fr;
503
+ /* ── Status dashboard ── */
504
+ .status-panel {
505
+ width: 100%;
506
+ height: 100%;
507
+ overflow-y: auto;
508
+ padding: 32px;
509
+ }
510
+ .status-inner {
511
+ max-width: 720px;
512
+ margin: 0 auto;
513
+ }
514
+ .status-hero {
515
+ display: flex;
516
+ align-items: center;
357
517
  gap: 12px;
518
+ margin-bottom: 28px;
519
+ }
520
+ .status-dot {
521
+ width: 10px;
522
+ height: 10px;
523
+ border-radius: 50%;
524
+ flex-shrink: 0;
525
+ }
526
+ .status-dot.green { background: var(--green); box-shadow: 0 0 8px rgba(74, 222, 128, 0.4); }
527
+ .status-dot.red { background: var(--red); box-shadow: 0 0 8px rgba(248, 113, 113, 0.4); }
528
+ .status-headline {
529
+ font-size: 16px;
530
+ font-weight: 600;
531
+ color: var(--text-0);
358
532
  }
359
- .status-card {
360
- background: var(--surface);
533
+ .status-sub {
534
+ font-size: 12px;
535
+ color: var(--text-2);
536
+ margin-top: 1px;
537
+ }
538
+
539
+ .metrics-row {
540
+ display: grid;
541
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
542
+ gap: 1px;
543
+ background: var(--border-subtle);
361
544
  border: 1px solid var(--border);
362
545
  border-radius: var(--radius);
363
- padding: 16px;
546
+ overflow: hidden;
364
547
  }
365
- .status-card h3 {
366
- font-size: 12px;
548
+ .metric {
549
+ background: var(--bg-1);
550
+ padding: 16px 18px;
551
+ }
552
+ .metric-value {
553
+ font-size: 20px;
554
+ font-weight: 600;
555
+ font-family: var(--font-mono);
556
+ color: var(--text-0);
557
+ line-height: 1.2;
558
+ }
559
+ .metric-label {
560
+ font-size: 11px;
561
+ font-weight: 500;
367
562
  text-transform: uppercase;
368
563
  letter-spacing: 0.5px;
369
- color: var(--text-muted);
370
- margin-bottom: 8px;
564
+ color: var(--text-2);
565
+ margin-top: 4px;
371
566
  }
372
- .status-card .value {
373
- font-size: 24px;
374
- font-weight: 700;
567
+ .metric-sub {
568
+ font-size: 11px;
569
+ color: var(--text-2);
375
570
  font-family: var(--font-mono);
571
+ margin-top: 2px;
376
572
  }
377
- .status-card .sub {
573
+ .metric-value.green { color: var(--green); }
574
+ .metric-value.red { color: var(--red); }
575
+ .metric-value.amber { color: var(--amber); }
576
+
577
+ .queue-section {
578
+ margin-top: 20px;
579
+ }
580
+ .queue-label {
581
+ font-size: 11px;
582
+ font-weight: 600;
583
+ text-transform: uppercase;
584
+ letter-spacing: 0.5px;
585
+ color: var(--text-2);
586
+ margin-bottom: 8px;
587
+ }
588
+ .queue-bar-wrap {
589
+ display: flex;
590
+ align-items: center;
591
+ gap: 12px;
592
+ margin-bottom: 8px;
593
+ }
594
+ .queue-bar {
595
+ flex: 1;
596
+ height: 4px;
597
+ background: var(--bg-3);
598
+ border-radius: 2px;
599
+ overflow: hidden;
600
+ }
601
+ .queue-bar-fill {
602
+ height: 100%;
603
+ border-radius: 2px;
604
+ background: var(--accent);
605
+ transition: width 0.3s;
606
+ }
607
+ .queue-bar-fill.warn { background: var(--amber); }
608
+ .queue-bar-fill.crit { background: var(--red); }
609
+ .queue-text {
378
610
  font-size: 12px;
379
- color: var(--text-muted);
380
- margin-top: 4px;
611
+ font-family: var(--font-mono);
612
+ color: var(--text-1);
613
+ min-width: 70px;
614
+ text-align: right;
615
+ }
616
+
617
+ .status-footer {
618
+ margin-top: 20px;
619
+ font-size: 11px;
620
+ color: var(--text-2);
381
621
  }
382
- .cb-closed { color: var(--success); }
383
- .cb-open { color: var(--error); }
384
- .cb-half-open { color: var(--warning); }
385
622
 
386
- /* WebSocket */
623
+ .status-error {
624
+ color: var(--red);
625
+ padding: 20px;
626
+ font-size: 14px;
627
+ }
628
+
629
+ /* ── WebSocket ── */
387
630
  .ws-controls {
388
631
  display: flex;
389
- gap: 8px;
390
- margin-bottom: 12px;
391
632
  align-items: center;
633
+ gap: 10px;
634
+ margin-bottom: 12px;
392
635
  }
393
- .ws-status {
394
- width: 10px;
395
- height: 10px;
636
+ .ws-dot {
637
+ width: 8px;
638
+ height: 8px;
396
639
  border-radius: 50%;
397
- background: var(--error);
398
- display: inline-block;
640
+ background: var(--text-2);
641
+ flex-shrink: 0;
642
+ transition: background 0.2s, box-shadow 0.2s;
643
+ }
644
+ .ws-dot.connected { background: var(--green); box-shadow: 0 0 6px rgba(74,222,128,0.4); }
645
+ .ws-dot.connecting { background: var(--amber); box-shadow: 0 0 6px rgba(251,191,36,0.3); }
646
+ .ws-label {
647
+ font-size: 12px;
648
+ color: var(--text-2);
649
+ font-family: var(--font-mono);
399
650
  }
400
- .ws-status.connected { background: var(--success); }
401
- .ws-status.connecting { background: var(--warning); }
402
651
 
403
652
  .ws-log {
404
- background: var(--bg);
405
- border: 1px solid var(--border);
406
- border-radius: var(--radius);
407
- padding: 12px;
408
- font-family: var(--font-mono);
409
- font-size: 13px;
410
- max-height: 400px;
653
+ flex: 1;
411
654
  overflow-y: auto;
412
- margin-bottom: 12px;
655
+ background: var(--bg-0);
656
+ border: 1px solid var(--border-subtle);
657
+ border-radius: var(--radius-sm);
658
+ padding: 12px 14px;
659
+ font-family: var(--font-mono);
660
+ font-size: 12px;
661
+ line-height: 1.6;
413
662
  }
414
- .ws-msg { margin-bottom: 6px; }
663
+ .ws-msg { margin-bottom: 2px; }
415
664
  .ws-msg.sent { color: var(--accent); }
416
- .ws-msg.received { color: var(--success); }
417
- .ws-msg.system { color: var(--text-muted); font-style: italic; }
665
+ .ws-msg.received { color: var(--green); }
666
+ .ws-msg.system { color: var(--text-2); }
667
+ .ws-msg .ws-prefix {
668
+ display: inline-block;
669
+ width: 16px;
670
+ text-align: center;
671
+ margin-right: 6px;
672
+ opacity: 0.6;
673
+ }
418
674
 
419
- .ws-input-row {
675
+ .ws-input-wrap {
420
676
  display: flex;
421
677
  gap: 8px;
678
+ margin-top: 12px;
679
+ flex-shrink: 0;
680
+ }
681
+ .ws-input-wrap textarea {
682
+ min-height: 50px;
683
+ flex: 1;
422
684
  }
423
- .ws-input-row textarea {
424
- min-height: 60px;
685
+ .ws-input-wrap .btn {
686
+ align-self: flex-end;
425
687
  }
426
688
 
427
- /* Utilities */
689
+ /* ── Utilities ── */
428
690
  .hidden { display: none !important; }
429
- .mt-16 { margin-top: 16px; }
430
- .mb-8 { margin-bottom: 8px; }
691
+ .gap-8 { gap: 8px; }
692
+ .mt-12 { margin-top: 12px; }
693
+ .mb-6 { margin-bottom: 6px; }
694
+
695
+ /* ── Skeleton loading ── */
696
+ @keyframes shimmer {
697
+ 0% { opacity: 0.5; }
698
+ 50% { opacity: 0.2; }
699
+ 100% { opacity: 0.5; }
700
+ }
701
+ .skeleton {
702
+ background: var(--bg-3);
703
+ border-radius: 4px;
704
+ animation: shimmer 1.5s ease-in-out infinite;
705
+ }
431
706
  `;
432
707
  }
433
708
  function playgroundJS(port) {
434
709
  return `
435
710
  (function() {
436
- const PORT = ${port};
437
- const BASE = location.origin;
438
- const WS_BASE = BASE.replace(/^http/, 'ws');
439
-
440
- // Request templates per tab
441
- const TEMPLATES = {
442
- run: JSON.stringify({ prompt: "What is 2 + 2?", options: { maxTurns: 1 } }, null, 2),
443
- stream: JSON.stringify({ prompt: "Write a haiku about coding", options: { maxTurns: 1 } }, null, 2),
444
- chat: JSON.stringify({
445
- model: "claude-sonnet-4-20250514",
446
- messages: [{ role: "user", content: "Hello! What can you do?" }],
447
- stream: false
448
- }, null, 2),
449
- ws: JSON.stringify({ type: "start", prompt: "Hello from WebSocket" }, null, 2)
450
- };
711
+ var PORT = ${port};
712
+ var BASE = location.origin;
713
+ var WS_BASE = BASE.replace(/^http/, 'ws');
451
714
 
452
- // Endpoints per tab
453
- const ENDPOINTS = {
454
- status: { method: 'GET', path: '/api/status' },
715
+ var ENDPOINTS = {
455
716
  run: { method: 'POST', path: '/api/run' },
456
717
  stream: { method: 'POST', path: '/api/stream' },
457
718
  chat: { method: 'POST', path: '/v1/chat/completions' },
458
719
  };
459
720
 
460
- // State
461
- const state = {
721
+ var state = {
462
722
  activeTab: 'status',
463
723
  apiKey: localStorage.getItem('otterly_api_key') || '',
464
- sessionId: '',
465
724
  ws: null,
466
725
  wsConnected: false,
467
726
  activeAbort: null,
468
727
  statusInterval: null,
469
728
  autoScroll: true,
470
- responseMode: 'formatted', // 'formatted' | 'raw'
471
729
  history: [],
472
730
  };
473
731
 
474
- // DOM refs
475
- const $ = (sel) => document.querySelector(sel);
476
- const $$ = (sel) => document.querySelectorAll(sel);
732
+ var $ = function(sel) { return document.querySelector(sel); };
733
+ var $$ = function(sel) { return document.querySelectorAll(sel); };
734
+
735
+ var chevronSvg = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
477
736
 
478
- // Init
479
- document.addEventListener('DOMContentLoaded', () => {
480
- // Restore API key
481
- const keyInput = $('#api-key');
737
+ // ── Init ──
738
+ document.addEventListener('DOMContentLoaded', function() {
739
+ var keyInput = $('#api-key');
482
740
  keyInput.value = state.apiKey;
483
- keyInput.addEventListener('input', (e) => {
741
+ keyInput.addEventListener('input', function(e) {
484
742
  state.apiKey = e.target.value;
485
743
  localStorage.setItem('otterly_api_key', state.apiKey);
486
744
  });
487
745
 
488
- // Tab switching
489
- $$('.tab').forEach((tab) => {
490
- tab.addEventListener('click', () => switchTab(tab.dataset.tab));
746
+ $$('.nav-item').forEach(function(btn) {
747
+ btn.addEventListener('click', function() { switchTab(btn.dataset.tab); });
491
748
  });
492
749
 
493
- // Section headers (collapsible)
494
- $$('.section-header').forEach((h) => {
495
- h.addEventListener('click', () => {
750
+ $$('.section-toggle').forEach(function(h) {
751
+ h.addEventListener('click', function() {
496
752
  h.classList.toggle('open');
497
- const target = h.nextElementSibling;
753
+ var target = h.nextElementSibling;
498
754
  if (target) target.classList.toggle('open');
499
755
  });
500
756
  });
501
757
 
502
- // Send buttons
503
- $('#send-run')?.addEventListener('click', () => sendRequest('run'));
504
- $('#send-stream')?.addEventListener('click', () => sendStream());
505
- $('#send-chat')?.addEventListener('click', () => sendChat());
758
+ $('#send-run').addEventListener('click', function() { sendRequest('run'); });
759
+ $('#send-stream').addEventListener('click', function() { sendStream(); });
760
+ $('#send-chat').addEventListener('click', function() { sendChat(); });
506
761
 
507
- // Cancel buttons
508
- $$('.btn-cancel').forEach((btn) => {
762
+ $$('.btn-cancel').forEach(function(btn) {
509
763
  btn.addEventListener('click', cancelRequest);
510
764
  });
511
765
 
512
- // Response tab switching
513
- $$('.response-tab').forEach((t) => {
514
- t.addEventListener('click', (e) => {
515
- const panel = e.target.closest('.panel');
516
- panel.querySelectorAll('.response-tab').forEach((rt) => rt.classList.remove('active'));
517
- e.target.classList.add('active');
518
- state.responseMode = e.target.dataset.mode;
519
- // Re-render if we have data
520
- const formatted = panel.querySelector('.response-formatted');
521
- const raw = panel.querySelector('.response-raw');
522
- if (formatted && raw) {
523
- formatted.classList.toggle('hidden', state.responseMode !== 'formatted');
524
- raw.classList.toggle('hidden', state.responseMode !== 'raw');
525
- }
766
+ // Mode toggles (formatted/raw)
767
+ $$('.mode-toggle').forEach(function(group) {
768
+ group.querySelectorAll('button').forEach(function(btn) {
769
+ btn.addEventListener('click', function() {
770
+ group.querySelectorAll('button').forEach(function(b) { b.classList.remove('active'); });
771
+ btn.classList.add('active');
772
+ var panel = group.closest('.pane') || group.closest('.panel');
773
+ var formatted = panel.querySelector('.response-formatted');
774
+ var raw = panel.querySelector('.response-raw');
775
+ if (formatted && raw) {
776
+ var mode = btn.dataset.mode;
777
+ formatted.classList.toggle('hidden', mode !== 'formatted');
778
+ raw.classList.toggle('hidden', mode !== 'raw');
779
+ }
780
+ });
526
781
  });
527
782
  });
528
783
 
529
- // WebSocket controls
530
- $('#ws-connect')?.addEventListener('click', wsConnect);
531
- $('#ws-disconnect')?.addEventListener('click', wsDisconnect);
532
- $('#ws-send')?.addEventListener('click', wsSend);
784
+ // WebSocket
785
+ $('#ws-connect').addEventListener('click', wsConnect);
786
+ $('#ws-disconnect').addEventListener('click', wsDisconnect);
787
+ $('#ws-send').addEventListener('click', wsSend);
533
788
 
534
- // Jump to bottom
535
- $$('.jump-bottom').forEach((btn) => {
536
- btn.addEventListener('click', () => {
537
- const container = btn.closest('.stream-output') || btn.closest('.ws-log');
789
+ // Jump buttons
790
+ $$('.jump-btn').forEach(function(btn) {
791
+ btn.addEventListener('click', function() {
792
+ var container = btn.closest('.stream-area') || btn.closest('.ws-log');
538
793
  if (container) {
539
794
  container.scrollTop = container.scrollHeight;
540
795
  state.autoScroll = true;
@@ -543,26 +798,23 @@ function playgroundJS(port) {
543
798
  });
544
799
  });
545
800
 
546
- // Auto-scroll detection for stream outputs
547
- $$('.stream-output, .ws-log').forEach((el) => {
548
- el.addEventListener('scroll', () => {
549
- const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
801
+ $$('.stream-area, .ws-log').forEach(function(el) {
802
+ el.addEventListener('scroll', function() {
803
+ var atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
550
804
  state.autoScroll = atBottom;
551
- const jumpBtn = el.querySelector('.jump-bottom');
805
+ var jumpBtn = el.querySelector('.jump-btn');
552
806
  if (jumpBtn) jumpBtn.style.display = atBottom ? 'none' : 'block';
553
807
  });
554
808
  });
555
809
 
556
- // Start with status tab
557
810
  switchTab('status');
558
811
  });
559
812
 
560
813
  function switchTab(tab) {
561
814
  state.activeTab = tab;
562
- $$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
563
- $$('.panel').forEach((p) => p.classList.toggle('active', p.id === 'panel-' + tab));
815
+ $$('.nav-item').forEach(function(t) { t.classList.toggle('active', t.dataset.tab === tab); });
816
+ $$('.panel').forEach(function(p) { p.classList.toggle('active', p.id === 'panel-' + tab); });
564
817
 
565
- // Start/stop status polling
566
818
  if (tab === 'status') {
567
819
  fetchStatus();
568
820
  state.statusInterval = setInterval(fetchStatus, 5000);
@@ -573,97 +825,148 @@ function playgroundJS(port) {
573
825
  }
574
826
 
575
827
  function getHeaders() {
576
- const h = { 'Content-Type': 'application/json' };
828
+ var h = { 'Content-Type': 'application/json' };
577
829
  if (state.apiKey) h['Authorization'] = 'Bearer ' + state.apiKey;
578
- const sid = $('#session-id-' + state.activeTab);
830
+ var sid = $('#session-id-' + state.activeTab);
579
831
  if (sid && sid.value) h['X-Session-Id'] = sid.value;
580
832
  return h;
581
833
  }
582
834
 
835
+ // ── JSON syntax highlighting ──
836
+ function syntaxHighlight(json) {
837
+ if (typeof json !== 'string') json = JSON.stringify(json, null, 2);
838
+ json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
839
+ return json.replace(
840
+ /("(\\\\u[a-zA-Z0-9]{4}|\\\\[^u]|[^\\\\"])*"(\\s*:)?|\\b(true|false|null)\\b|-?\\d+(?:\\.\\d*)?(?:[eE][+\\-]?\\d+)?)/g,
841
+ function(match) {
842
+ var cls = 'json-number';
843
+ if (/^"/.test(match)) {
844
+ if (/:$/.test(match)) cls = 'json-key';
845
+ else cls = 'json-string';
846
+ } else if (/true|false/.test(match)) {
847
+ cls = 'json-boolean';
848
+ } else if (/null/.test(match)) {
849
+ cls = 'json-null';
850
+ }
851
+ return '<span class="' + cls + '">' + match + '</span>';
852
+ }
853
+ );
854
+ }
855
+
583
856
  // ── Status ──
857
+ var lastStatusData = null;
584
858
  async function fetchStatus() {
585
859
  try {
586
- const res = await fetch(BASE + '/api/status');
587
- const data = await res.json();
860
+ var res = await fetch(BASE + '/api/status');
861
+ var data = await res.json();
862
+ lastStatusData = data;
588
863
  renderStatus(data);
589
864
  } catch (err) {
590
- $('#status-content').innerHTML = '<div style="color:var(--error)">Failed to connect: ' + escHtml(err.message) + '</div>';
865
+ $('#status-content').innerHTML = '<div class="status-error">Failed to connect: ' + escHtml(err.message) + '</div>';
591
866
  }
592
867
  }
593
868
 
594
- function renderStatus(data) {
595
- const cb = data.circuitBreaker || 'closed';
596
- const cbClass = cb === 'closed' ? 'cb-closed' : cb === 'open' ? 'cb-open' : 'cb-half-open';
597
- const q = data.queue || {};
869
+ function renderStatus(d) {
870
+ var isOk = d.status === 'ok';
871
+ var cb = d.circuitBreaker || 'closed';
872
+ var cbClass = cb === 'closed' ? 'green' : cb === 'open' ? 'red' : 'amber';
873
+ var q = d.queue || {};
874
+ var activeP = q.maxConcurrent ? Math.round((q.active || 0) / q.maxConcurrent * 100) : 0;
875
+ var queuedP = q.maxQueueSize ? Math.round((q.queued || 0) / q.maxQueueSize * 100) : 0;
876
+ var activeBarClass = activeP > 80 ? 'crit' : activeP > 50 ? 'warn' : '';
877
+ var queuedBarClass = queuedP > 80 ? 'crit' : queuedP > 50 ? 'warn' : '';
878
+
598
879
  $('#status-content').innerHTML =
599
- '<div class="status-grid">' +
600
- '<div class="status-card"><h3>Status</h3><div class="value" style="color:var(--success)">' + escHtml(data.status) + '</div></div>' +
601
- '<div class="status-card"><h3>Version</h3><div class="value">' + escHtml(data.version) + '</div></div>' +
602
- '<div class="status-card"><h3>Active Sessions</h3><div class="value">' + data.activeSessions + '</div></div>' +
603
- '<div class="status-card"><h3>Circuit Breaker</h3><div class="value ' + cbClass + '">' + escHtml(cb) + '</div></div>' +
604
- '<div class="status-card"><h3>Queue Active</h3><div class="value">' + (q.active || 0) + '</div>' +
605
- '<div class="sub">Max concurrent: ' + (q.maxConcurrent || '-') + '</div></div>' +
606
- '<div class="status-card"><h3>Queue Waiting</h3><div class="value">' + (q.queued || 0) + '</div>' +
607
- '<div class="sub">Max size: ' + (q.maxQueueSize || '-') + '</div></div>' +
880
+ '<div class="status-inner">' +
881
+ '<div class="status-hero">' +
882
+ '<div class="status-dot ' + (isOk ? 'green' : 'red') + '"></div>' +
883
+ '<div>' +
884
+ '<div class="status-headline">' + (isOk ? 'All systems operational' : 'System issue detected') + '</div>' +
885
+ '<div class="status-sub">v' + escHtml(d.version || '?') + '</div>' +
886
+ '</div>' +
887
+ '</div>' +
888
+ '<div class="metrics-row">' +
889
+ '<div class="metric"><div class="metric-value">' + escHtml(d.version || '?') + '</div><div class="metric-label">Version</div></div>' +
890
+ '<div class="metric"><div class="metric-value">' + (d.activeSessions || 0) + '</div><div class="metric-label">Sessions</div></div>' +
891
+ '<div class="metric"><div class="metric-value ' + cbClass + '">' + escHtml(cb) + '</div><div class="metric-label">Circuit</div></div>' +
892
+ '<div class="metric"><div class="metric-value">' + (q.active || 0) + '</div><div class="metric-label">Active</div><div class="metric-sub">/ ' + (q.maxConcurrent || '?') + ' max</div></div>' +
893
+ '<div class="metric"><div class="metric-value">' + (q.queued || 0) + '</div><div class="metric-label">Queued</div><div class="metric-sub">/ ' + (q.maxQueueSize || '?') + ' max</div></div>' +
894
+ '</div>' +
895
+ '<div class="queue-section">' +
896
+ '<div class="queue-label">Queue utilization</div>' +
897
+ '<div class="queue-bar-wrap">' +
898
+ '<div style="font-size:12px;color:var(--text-2);min-width:50px">Active</div>' +
899
+ '<div class="queue-bar"><div class="queue-bar-fill ' + activeBarClass + '" style="width:' + Math.max(activeP, 1) + '%"></div></div>' +
900
+ '<div class="queue-text">' + (q.active || 0) + ' / ' + (q.maxConcurrent || '?') + '</div>' +
901
+ '</div>' +
902
+ '<div class="queue-bar-wrap">' +
903
+ '<div style="font-size:12px;color:var(--text-2);min-width:50px">Waiting</div>' +
904
+ '<div class="queue-bar"><div class="queue-bar-fill ' + queuedBarClass + '" style="width:' + Math.max(queuedP, 1) + '%"></div></div>' +
905
+ '<div class="queue-text">' + (q.queued || 0) + ' / ' + (q.maxQueueSize || '?') + '</div>' +
906
+ '</div>' +
907
+ '</div>' +
908
+ '<div class="status-footer">Auto-refreshing every 5s</div>' +
608
909
  '</div>';
609
910
  }
610
911
 
611
912
  // ── Run (one-shot) ──
612
913
  async function sendRequest(tab) {
613
- const bodyEl = $('#body-' + tab);
614
- const resBar = $('#res-bar-' + tab);
615
- const resFormatted = $('#res-formatted-' + tab);
616
- const resRaw = $('#res-raw-' + tab);
617
- const sendBtn = $('#send-' + tab);
618
-
619
- let body;
620
- try {
621
- body = JSON.parse(bodyEl.value);
622
- } catch (e) {
623
- resBar.innerHTML = '<span class="status-badge err">JSON Error</span> <span>' + escHtml(e.message) + '</span>';
914
+ var bodyEl = $('#body-' + tab);
915
+ var resStatus = $('#res-status-' + tab);
916
+ var resFormatted = $('#res-formatted-' + tab);
917
+ var resRaw = $('#res-raw-' + tab);
918
+ var sendBtn = $('#send-' + tab);
919
+ var emptyEl = $('#res-empty-' + tab);
920
+
921
+ var body;
922
+ try { body = JSON.parse(bodyEl.value); } catch (e) {
923
+ resStatus.innerHTML = '<span class="status-pill err">JSON Error</span> <span style="color:var(--text-1);font-size:12px">' + escHtml(e.message) + '</span>';
624
924
  return;
625
925
  }
626
926
 
627
927
  sendBtn.disabled = true;
628
- resBar.innerHTML = '<span class="status-badge" style="background:var(--warning);color:var(--bg)">Pending</span>';
629
- resFormatted.textContent = '';
928
+ if (emptyEl) emptyEl.classList.add('hidden');
929
+ resStatus.innerHTML = '<span class="status-pill pending">Pending</span>';
930
+ resFormatted.innerHTML = '';
630
931
  resRaw.textContent = '';
631
932
 
632
- const startTime = Date.now();
633
- const controller = new AbortController();
933
+ var startTime = Date.now();
934
+ var controller = new AbortController();
634
935
  state.activeAbort = controller;
635
936
 
636
937
  try {
637
- const ep = ENDPOINTS[tab];
638
- const res = await fetch(BASE + ep.path, {
938
+ var ep = ENDPOINTS[tab];
939
+ var res = await fetch(BASE + ep.path, {
639
940
  method: ep.method,
640
941
  headers: getHeaders(),
641
942
  body: JSON.stringify(body),
642
943
  signal: controller.signal,
643
944
  });
644
-
645
- const duration = Date.now() - startTime;
646
- const raw = await res.text();
647
- let parsed;
945
+ var duration = Date.now() - startTime;
946
+ var raw = await res.text();
947
+ var parsed;
648
948
  try { parsed = JSON.parse(raw); } catch { parsed = raw; }
649
949
 
650
- const ok = res.status >= 200 && res.status < 300;
651
- resBar.innerHTML =
652
- '<span class="status-badge ' + (ok ? 'ok' : 'err') + '">' + res.status + ' ' + (res.statusText || '') + '</span>' +
653
- '<span class="duration">' + duration + 'ms</span>';
950
+ var ok = res.status >= 200 && res.status < 300;
951
+ resStatus.innerHTML =
952
+ '<span class="status-pill ' + (ok ? 'ok' : 'err') + '">' + res.status + '</span>' +
953
+ '<span class="res-duration">' + duration + 'ms</span>';
654
954
 
655
- resFormatted.textContent = typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : raw;
955
+ if (typeof parsed === 'object') {
956
+ resFormatted.innerHTML = syntaxHighlight(parsed);
957
+ } else {
958
+ resFormatted.textContent = raw;
959
+ }
656
960
  resRaw.textContent = raw;
657
961
 
658
- // Update session ID from response
659
- const newSid = res.headers.get('X-Session-Id');
660
- const sidInput = $('#session-id-' + tab);
962
+ var newSid = res.headers.get('X-Session-Id');
963
+ var sidInput = $('#session-id-' + tab);
661
964
  if (newSid && sidInput) sidInput.value = newSid;
662
965
  } catch (err) {
663
966
  if (err.name === 'AbortError') {
664
- resBar.innerHTML = '<span class="status-badge" style="background:var(--text-muted);color:var(--bg)">Cancelled</span>';
967
+ resStatus.innerHTML = '<span class="status-pill cancelled">Cancelled</span>';
665
968
  } else {
666
- resBar.innerHTML = '<span class="status-badge err">Error</span> <span>' + escHtml(err.message) + '</span>';
969
+ resStatus.innerHTML = '<span class="status-pill err">Error</span> <span style="color:var(--text-1);font-size:12px">' + escHtml(err.message) + '</span>';
667
970
  }
668
971
  } finally {
669
972
  sendBtn.disabled = false;
@@ -673,18 +976,16 @@ function playgroundJS(port) {
673
976
 
674
977
  // ── Stream (NDJSON) ──
675
978
  async function sendStream() {
676
- const bodyEl = $('#body-stream');
677
- const output = $('#stream-output');
678
- const textArea = $('#stream-text');
679
- const rawArea = $('#stream-raw');
680
- const summary = $('#stream-summary');
681
- const sendBtn = $('#send-stream');
682
- const rawToggle = $('#stream-raw-toggle');
683
-
684
- let body;
685
- try {
686
- body = JSON.parse(bodyEl.value);
687
- } catch (e) {
979
+ var bodyEl = $('#body-stream');
980
+ var output = $('#stream-output');
981
+ var textArea = $('#stream-text');
982
+ var rawArea = $('#stream-raw');
983
+ var summary = $('#stream-summary');
984
+ var sendBtn = $('#send-stream');
985
+ var rawToggle = $('#stream-raw-toggle');
986
+
987
+ var body;
988
+ try { body = JSON.parse(bodyEl.value); } catch (e) {
688
989
  output.classList.remove('hidden');
689
990
  textArea.textContent = 'JSON parse error: ' + e.message;
690
991
  return;
@@ -697,11 +998,11 @@ function playgroundJS(port) {
697
998
  summary.classList.add('hidden');
698
999
  state.autoScroll = true;
699
1000
 
700
- const controller = new AbortController();
1001
+ var controller = new AbortController();
701
1002
  state.activeAbort = controller;
702
- let showRaw = false;
1003
+ var showRaw = false;
703
1004
  rawToggle.textContent = 'Raw';
704
- rawToggle.onclick = () => {
1005
+ rawToggle.onclick = function() {
705
1006
  showRaw = !showRaw;
706
1007
  rawToggle.textContent = showRaw ? 'Formatted' : 'Raw';
707
1008
  textArea.classList.toggle('hidden', showRaw);
@@ -709,71 +1010,59 @@ function playgroundJS(port) {
709
1010
  };
710
1011
  rawArea.classList.add('hidden');
711
1012
 
712
- let fullText = '';
1013
+ var fullText = '';
713
1014
 
714
1015
  try {
715
- const res = await fetch(BASE + '/api/stream', {
1016
+ var res = await fetch(BASE + '/api/stream', {
716
1017
  method: 'POST',
717
1018
  headers: getHeaders(),
718
1019
  body: JSON.stringify(body),
719
1020
  signal: controller.signal,
720
1021
  });
721
1022
 
722
- const reader = res.body.getReader();
723
- const decoder = new TextDecoder();
724
- let buffer = '';
1023
+ var reader = res.body.getReader();
1024
+ var decoder = new TextDecoder();
1025
+ var buffer = '';
725
1026
 
726
1027
  while (true) {
727
- const { done, value } = await reader.read();
728
- if (done) break;
1028
+ var chunk = await reader.read();
1029
+ if (chunk.done) break;
729
1030
 
730
- buffer += decoder.decode(value, { stream: true });
731
- const lines = buffer.split('\\n');
732
- buffer = lines.pop(); // keep incomplete line
1031
+ buffer += decoder.decode(chunk.value, { stream: true });
1032
+ var lines = buffer.split('\\n');
1033
+ buffer = lines.pop();
733
1034
 
734
- for (const line of lines) {
1035
+ for (var i = 0; i < lines.length; i++) {
1036
+ var line = lines[i];
735
1037
  if (!line.trim()) continue;
736
1038
  rawArea.textContent += line + '\\n';
737
1039
 
738
- let event;
1040
+ var event;
739
1041
  try { event = JSON.parse(line); } catch { continue; }
740
1042
 
741
1043
  if (event.type === 'text_delta') {
742
1044
  fullText += event.text || '';
743
1045
  textArea.innerHTML = escHtml(fullText) + '<span class="cursor-blink"></span>';
744
1046
  } else if (event.type === 'tool_use') {
745
- const block = document.createElement('div');
746
- block.className = 'tool-call-block';
747
- block.innerHTML =
748
- '<div class="tool-call-header">' + escHtml(event.name || 'tool_use') + '</div>' +
749
- '<div class="tool-call-body"><pre>' + escHtml(JSON.stringify(event.input || {}, null, 2)) + '</pre></div>';
750
- block.querySelector('.tool-call-header').onclick = () => block.classList.toggle('open');
1047
+ var block = makeToolBlock(event.name || 'tool_use', JSON.stringify(event.input || {}, null, 2));
751
1048
  textArea.insertBefore(block, textArea.querySelector('.cursor-blink'));
752
1049
  } else if (event.type === 'tool_result') {
753
- const block = document.createElement('div');
754
- block.className = 'tool-call-block ' + (event.isError ? 'tool-result-err' : 'tool-result-ok');
755
- block.innerHTML =
756
- '<div class="tool-call-header">Result' + (event.isError ? ' (error)' : '') + '</div>' +
757
- '<div class="tool-call-body"><pre>' + escHtml(typeof event.content === 'string' ? event.content : JSON.stringify(event.content, null, 2)) + '</pre></div>';
758
- block.querySelector('.tool-call-header').onclick = () => block.classList.toggle('open');
759
- textArea.insertBefore(block, textArea.querySelector('.cursor-blink'));
1050
+ var content = typeof event.content === 'string' ? event.content : JSON.stringify(event.content, null, 2);
1051
+ var block2 = makeToolBlock('Result' + (event.isError ? ' (error)' : ''), content, event.isError ? 'result-err' : 'result-ok');
1052
+ textArea.insertBefore(block2, textArea.querySelector('.cursor-blink'));
760
1053
  } else if (event.type === 'result') {
761
- // Remove cursor
762
- const cur = textArea.querySelector('.cursor-blink');
1054
+ var cur = textArea.querySelector('.cursor-blink');
763
1055
  if (cur) cur.remove();
764
- // Show summary
765
1056
  summary.classList.remove('hidden');
766
1057
  summary.innerHTML =
767
- '<span>Cost: $' + (event.cost || 0).toFixed(4) + '</span>' +
768
- '<span>Duration: ' + (event.duration || 0) + 'ms</span>' +
769
- (event.usage ? '<span>Tokens: ' + (event.usage.inputTokens || 0) + ' in / ' + (event.usage.outputTokens || 0) + ' out</span>' : '');
770
-
771
- // Update session ID
772
- const sidInput = $('#session-id-stream');
1058
+ '<span><span class="label">Cost</span>$' + (event.cost || 0).toFixed(4) + '</span>' +
1059
+ '<span><span class="label">Duration</span>' + (event.duration || 0) + 'ms</span>' +
1060
+ (event.usage ? '<span><span class="label">Tokens</span>' + (event.usage.inputTokens || 0) + ' in / ' + (event.usage.outputTokens || 0) + ' out</span>' : '');
1061
+ var sidInput = $('#session-id-stream');
773
1062
  if (event.sessionId && sidInput) sidInput.value = event.sessionId;
774
1063
  } else if (event.type === 'session_init' && event.sessionId) {
775
- const sidInput = $('#session-id-stream');
776
- if (sidInput) sidInput.value = event.sessionId;
1064
+ var sidInput2 = $('#session-id-stream');
1065
+ if (sidInput2) sidInput2.value = event.sessionId;
777
1066
  }
778
1067
 
779
1068
  if (state.autoScroll) output.scrollTop = output.scrollHeight;
@@ -781,81 +1070,89 @@ function playgroundJS(port) {
781
1070
  }
782
1071
  } catch (err) {
783
1072
  if (err.name !== 'AbortError') {
784
- textArea.innerHTML = fullText + '\\n<span style="color:var(--error)">Error: ' + escHtml(err.message) + '</span>';
1073
+ textArea.innerHTML = fullText + '\\n<span style="color:var(--red)">Error: ' + escHtml(err.message) + '</span>';
785
1074
  } else {
786
- const cur = textArea.querySelector('.cursor-blink');
787
- if (cur) cur.remove();
788
- textArea.innerHTML += '\\n<span style="color:var(--text-muted)">[Cancelled]</span>';
1075
+ var cur2 = textArea.querySelector('.cursor-blink');
1076
+ if (cur2) cur2.remove();
1077
+ textArea.innerHTML += '\\n<span style="color:var(--text-2)">[Cancelled]</span>';
789
1078
  }
790
1079
  } finally {
791
1080
  sendBtn.disabled = false;
792
1081
  state.activeAbort = null;
793
- // Remove cursor if still present
794
- const cur = textArea.querySelector('.cursor-blink');
795
- if (cur) cur.remove();
1082
+ var cur3 = textArea.querySelector('.cursor-blink');
1083
+ if (cur3) cur3.remove();
796
1084
  }
797
1085
  }
798
1086
 
1087
+ function makeToolBlock(name, content, extraClass) {
1088
+ var block = document.createElement('div');
1089
+ block.className = 'tool-block' + (extraClass ? ' ' + extraClass : '');
1090
+ block.innerHTML =
1091
+ '<div class="tool-block-head">' + chevronSvg + ' ' + escHtml(name) + '</div>' +
1092
+ '<div class="tool-block-body"><pre>' + escHtml(content) + '</pre></div>';
1093
+ block.querySelector('.tool-block-head').onclick = function() { block.classList.toggle('open'); };
1094
+ return block;
1095
+ }
1096
+
799
1097
  // ── Chat (OpenAI) ──
800
1098
  async function sendChat() {
801
- const bodyEl = $('#body-chat');
802
- const sendBtn = $('#send-chat');
1099
+ var bodyEl = $('#body-chat');
1100
+ var sendBtn = $('#send-chat');
803
1101
 
804
- let body;
805
- try {
806
- body = JSON.parse(bodyEl.value);
807
- } catch (e) {
808
- $('#res-bar-chat').innerHTML = '<span class="status-badge err">JSON Error</span> <span>' + escHtml(e.message) + '</span>';
1102
+ var body;
1103
+ try { body = JSON.parse(bodyEl.value); } catch (e) {
1104
+ $('#res-status-chat').innerHTML = '<span class="status-pill err">JSON Error</span> <span style="color:var(--text-1);font-size:12px">' + escHtml(e.message) + '</span>';
809
1105
  return;
810
1106
  }
811
1107
 
812
- const isStream = body.stream === true;
813
-
814
- if (isStream) {
1108
+ if (body.stream === true) {
815
1109
  await sendChatStream(body, sendBtn);
816
1110
  } else {
817
- // Non-streaming: reuse sendRequest-style logic
818
1111
  sendBtn.disabled = true;
819
- const resBar = $('#res-bar-chat');
820
- const resFormatted = $('#res-formatted-chat');
821
- const resRaw = $('#res-raw-chat');
822
- resBar.innerHTML = '<span class="status-badge" style="background:var(--warning);color:var(--bg)">Pending</span>';
823
- resFormatted.textContent = '';
1112
+ var resStatus = $('#res-status-chat');
1113
+ var resFormatted = $('#res-formatted-chat');
1114
+ var resRaw = $('#res-raw-chat');
1115
+ var emptyEl = $('#res-empty-chat');
1116
+ resStatus.innerHTML = '<span class="status-pill pending">Pending</span>';
1117
+ resFormatted.innerHTML = '';
824
1118
  resRaw.textContent = '';
1119
+ if (emptyEl) emptyEl.classList.add('hidden');
825
1120
 
826
- // Show standard response, hide streaming
827
1121
  $('#chat-response-standard').classList.remove('hidden');
828
1122
  $('#chat-stream-output').classList.add('hidden');
829
1123
 
830
- const startTime = Date.now();
831
- const controller = new AbortController();
1124
+ var startTime = Date.now();
1125
+ var controller = new AbortController();
832
1126
  state.activeAbort = controller;
833
1127
 
834
1128
  try {
835
- const res = await fetch(BASE + '/v1/chat/completions', {
1129
+ var res = await fetch(BASE + '/v1/chat/completions', {
836
1130
  method: 'POST',
837
1131
  headers: getHeaders(),
838
1132
  body: JSON.stringify(body),
839
1133
  signal: controller.signal,
840
1134
  });
841
-
842
- const duration = Date.now() - startTime;
843
- const raw = await res.text();
844
- let parsed;
1135
+ var duration = Date.now() - startTime;
1136
+ var raw = await res.text();
1137
+ var parsed;
845
1138
  try { parsed = JSON.parse(raw); } catch { parsed = raw; }
846
1139
 
847
- const ok = res.status >= 200 && res.status < 300;
848
- resBar.innerHTML =
849
- '<span class="status-badge ' + (ok ? 'ok' : 'err') + '">' + res.status + ' ' + (res.statusText || '') + '</span>' +
850
- '<span class="duration">' + duration + 'ms</span>';
1140
+ var ok = res.status >= 200 && res.status < 300;
1141
+ resStatus.innerHTML =
1142
+ '<span class="status-pill ' + (ok ? 'ok' : 'err') + '">' + res.status + '</span>' +
1143
+ '<span class="res-duration">' + duration + 'ms</span>';
851
1144
 
852
- resFormatted.textContent = typeof parsed === 'object' ? JSON.stringify(parsed, null, 2) : raw;
1145
+ if (typeof parsed === 'object') {
1146
+ resFormatted.innerHTML = syntaxHighlight(parsed);
1147
+ } else {
1148
+ resFormatted.textContent = raw;
1149
+ }
853
1150
  resRaw.textContent = raw;
854
1151
  } catch (err) {
855
1152
  if (err.name === 'AbortError') {
856
- resBar.innerHTML = '<span class="status-badge" style="background:var(--text-muted);color:var(--bg)">Cancelled</span>';
1153
+ resStatus.innerHTML = '<span class="status-pill cancelled">Cancelled</span>';
857
1154
  } else {
858
- resBar.innerHTML = '<span class="status-badge err">Error</span> <span>' + escHtml(err.message) + '</span>';
1155
+ resStatus.innerHTML = '<span class="status-pill err">Error</span> <span style="color:var(--text-1);font-size:12px">' + escHtml(err.message) + '</span>';
859
1156
  }
860
1157
  } finally {
861
1158
  sendBtn.disabled = false;
@@ -865,73 +1162,68 @@ function playgroundJS(port) {
865
1162
  }
866
1163
 
867
1164
  async function sendChatStream(body, sendBtn) {
868
- const output = $('#chat-stream-output');
869
- const textArea = $('#chat-stream-text');
870
- const summary = $('#chat-stream-summary');
1165
+ var output = $('#chat-stream-output');
1166
+ var textArea = $('#chat-stream-text');
1167
+ var summary = $('#chat-stream-summary');
871
1168
 
872
1169
  sendBtn.disabled = true;
873
- // Hide standard response, show streaming
874
1170
  $('#chat-response-standard').classList.add('hidden');
875
1171
  output.classList.remove('hidden');
876
1172
  textArea.innerHTML = '<span class="cursor-blink"></span>';
877
1173
  summary.classList.add('hidden');
878
1174
  state.autoScroll = true;
879
1175
 
880
- const controller = new AbortController();
1176
+ var controller = new AbortController();
881
1177
  state.activeAbort = controller;
882
- let fullText = '';
883
- const startTime = Date.now();
1178
+ var fullText = '';
1179
+ var startTime = Date.now();
884
1180
 
885
1181
  try {
886
- const res = await fetch(BASE + '/v1/chat/completions', {
1182
+ var res = await fetch(BASE + '/v1/chat/completions', {
887
1183
  method: 'POST',
888
1184
  headers: getHeaders(),
889
1185
  body: JSON.stringify(body),
890
1186
  signal: controller.signal,
891
1187
  });
892
1188
 
893
- const reader = res.body.getReader();
894
- const decoder = new TextDecoder();
895
- let buffer = '';
1189
+ var reader = res.body.getReader();
1190
+ var decoder = new TextDecoder();
1191
+ var buffer = '';
896
1192
 
897
1193
  while (true) {
898
- const { done, value } = await reader.read();
899
- if (done) break;
1194
+ var chunk = await reader.read();
1195
+ if (chunk.done) break;
900
1196
 
901
- buffer += decoder.decode(value, { stream: true });
902
- const lines = buffer.split('\\n');
1197
+ buffer += decoder.decode(chunk.value, { stream: true });
1198
+ var lines = buffer.split('\\n');
903
1199
  buffer = lines.pop();
904
1200
 
905
- for (const line of lines) {
1201
+ for (var j = 0; j < lines.length; j++) {
1202
+ var line = lines[j];
906
1203
  if (!line.startsWith('data: ')) continue;
907
- const data = line.slice(6);
1204
+ var data = line.slice(6);
908
1205
  if (data === '[DONE]') {
909
- const cur = textArea.querySelector('.cursor-blink');
1206
+ var cur = textArea.querySelector('.cursor-blink');
910
1207
  if (cur) cur.remove();
911
- const duration = Date.now() - startTime;
1208
+ var duration = Date.now() - startTime;
912
1209
  summary.classList.remove('hidden');
913
- summary.innerHTML = '<span>Duration: ' + duration + 'ms</span><span>Stream complete</span>';
1210
+ summary.innerHTML = '<span><span class="label">Duration</span>' + duration + 'ms</span><span>Stream complete</span>';
914
1211
  continue;
915
1212
  }
916
1213
 
917
- let event;
1214
+ var event;
918
1215
  try { event = JSON.parse(data); } catch { continue; }
919
1216
 
920
- const delta = event.choices?.[0]?.delta;
921
- if (delta?.content) {
1217
+ var delta = event.choices && event.choices[0] && event.choices[0].delta;
1218
+ if (delta && delta.content) {
922
1219
  fullText += delta.content;
923
1220
  textArea.innerHTML = escHtml(fullText) + '<span class="cursor-blink"></span>';
924
1221
  }
925
- // Tool calls in streaming
926
- if (delta?.tool_calls) {
927
- for (const tc of delta.tool_calls) {
928
- if (tc.function?.name) {
929
- const block = document.createElement('div');
930
- block.className = 'tool-call-block';
931
- block.innerHTML =
932
- '<div class="tool-call-header">' + escHtml(tc.function.name) + '</div>' +
933
- '<div class="tool-call-body"><pre>' + escHtml(tc.function.arguments || '') + '</pre></div>';
934
- block.querySelector('.tool-call-header').onclick = () => block.classList.toggle('open');
1222
+ if (delta && delta.tool_calls) {
1223
+ for (var k = 0; k < delta.tool_calls.length; k++) {
1224
+ var tc = delta.tool_calls[k];
1225
+ if (tc.function && tc.function.name) {
1226
+ var block = makeToolBlock(tc.function.name, tc.function.arguments || '');
935
1227
  textArea.insertBefore(block, textArea.querySelector('.cursor-blink'));
936
1228
  }
937
1229
  }
@@ -942,89 +1234,81 @@ function playgroundJS(port) {
942
1234
  }
943
1235
  } catch (err) {
944
1236
  if (err.name !== 'AbortError') {
945
- textArea.innerHTML = fullText + '\\n<span style="color:var(--error)">Error: ' + escHtml(err.message) + '</span>';
1237
+ textArea.innerHTML = fullText + '\\n<span style="color:var(--red)">Error: ' + escHtml(err.message) + '</span>';
946
1238
  } else {
947
- const cur = textArea.querySelector('.cursor-blink');
948
- if (cur) cur.remove();
949
- textArea.innerHTML += '\\n<span style="color:var(--text-muted)">[Cancelled]</span>';
1239
+ var cur2 = textArea.querySelector('.cursor-blink');
1240
+ if (cur2) cur2.remove();
1241
+ textArea.innerHTML += '\\n<span style="color:var(--text-2)">[Cancelled]</span>';
950
1242
  }
951
1243
  } finally {
952
1244
  sendBtn.disabled = false;
953
1245
  state.activeAbort = null;
954
- const cur = textArea.querySelector('.cursor-blink');
955
- if (cur) cur.remove();
1246
+ var cur3 = textArea.querySelector('.cursor-blink');
1247
+ if (cur3) cur3.remove();
956
1248
  }
957
1249
  }
958
1250
 
959
1251
  // ── WebSocket ──
960
1252
  function wsConnect() {
961
1253
  if (state.ws) return;
962
- const log = $('#ws-log');
963
- const dot = $('#ws-dot');
1254
+ var dot = $('#ws-dot');
964
1255
 
965
- dot.className = 'ws-status connecting';
1256
+ dot.className = 'ws-dot connecting';
966
1257
  wsLog('Connecting to ' + WS_BASE + '/ws ...', 'system');
967
1258
 
968
- const ws = new WebSocket(WS_BASE + '/ws');
1259
+ var ws = new WebSocket(WS_BASE + '/ws');
969
1260
  state.ws = ws;
970
1261
 
971
- ws.onopen = () => {
1262
+ ws.onopen = function() {
972
1263
  state.wsConnected = true;
973
- dot.className = 'ws-status connected';
1264
+ dot.className = 'ws-dot connected';
974
1265
  wsLog('Connected', 'system');
975
1266
  $('#ws-connect').disabled = true;
976
1267
  $('#ws-disconnect').disabled = false;
977
1268
  $('#ws-send').disabled = false;
1269
+ $('#ws-label').textContent = 'Connected';
978
1270
  };
979
1271
 
980
- ws.onmessage = (e) => {
981
- let display;
982
- try {
983
- const parsed = JSON.parse(e.data);
984
- display = JSON.stringify(parsed, null, 2);
985
- } catch {
986
- display = e.data;
987
- }
1272
+ ws.onmessage = function(e) {
1273
+ var display;
1274
+ try { display = JSON.stringify(JSON.parse(e.data), null, 2); }
1275
+ catch { display = e.data; }
988
1276
  wsLog(display, 'received');
989
1277
  };
990
1278
 
991
- ws.onerror = () => {
992
- wsLog('Connection error', 'system');
993
- };
1279
+ ws.onerror = function() { wsLog('Connection error', 'system'); };
994
1280
 
995
- ws.onclose = (e) => {
1281
+ ws.onclose = function(e) {
996
1282
  state.ws = null;
997
1283
  state.wsConnected = false;
998
- dot.className = 'ws-status';
1284
+ dot.className = 'ws-dot';
999
1285
  wsLog('Disconnected (code: ' + e.code + ')', 'system');
1000
1286
  $('#ws-connect').disabled = false;
1001
1287
  $('#ws-disconnect').disabled = true;
1002
1288
  $('#ws-send').disabled = true;
1289
+ $('#ws-label').textContent = 'Disconnected';
1003
1290
  };
1004
1291
  }
1005
1292
 
1006
1293
  function wsDisconnect() {
1007
- if (state.ws) {
1008
- state.ws.close();
1009
- }
1294
+ if (state.ws) state.ws.close();
1010
1295
  }
1011
1296
 
1012
1297
  function wsSend() {
1013
1298
  if (!state.ws || state.ws.readyState !== WebSocket.OPEN) return;
1014
- const input = $('#ws-input');
1015
- const msg = input.value.trim();
1299
+ var input = $('#ws-input');
1300
+ var msg = input.value.trim();
1016
1301
  if (!msg) return;
1017
-
1018
1302
  state.ws.send(msg);
1019
1303
  wsLog(msg, 'sent');
1020
1304
  }
1021
1305
 
1022
1306
  function wsLog(text, type) {
1023
- const log = $('#ws-log');
1024
- const div = document.createElement('div');
1307
+ var log = $('#ws-log');
1308
+ var div = document.createElement('div');
1025
1309
  div.className = 'ws-msg ' + type;
1026
- const prefix = type === 'sent' ? '\\u25B6 ' : type === 'received' ? '\\u25C0 ' : '\\u2022 ';
1027
- div.textContent = prefix + text;
1310
+ var prefix = type === 'sent' ? '\\u25B6' : type === 'received' ? '\\u25C0' : '\\u2022';
1311
+ div.innerHTML = '<span class="ws-prefix">' + prefix + '</span>' + escHtml(text);
1028
1312
  log.appendChild(div);
1029
1313
  log.scrollTop = log.scrollHeight;
1030
1314
  }
@@ -1046,168 +1330,283 @@ function playgroundJS(port) {
1046
1330
  }
1047
1331
  function playgroundHTML() {
1048
1332
  return `
1049
- <div class="header">
1050
- <div class="header-left">
1051
- <span>\u{1F9A6}</span>
1052
- <span>otterly playground</span>
1053
- </div>
1054
- <div class="header-right">
1055
- <input type="password" id="api-key" class="api-key-input" placeholder="API Key (optional)">
1056
- </div>
1057
- </div>
1058
-
1059
- <div class="tabs">
1060
- <button class="tab active" data-tab="status">Status</button>
1061
- <button class="tab" data-tab="run">Run</button>
1062
- <button class="tab" data-tab="stream">Stream</button>
1063
- <button class="tab" data-tab="chat">Chat (OpenAI)</button>
1064
- <button class="tab" data-tab="ws">WebSocket</button>
1065
- </div>
1066
-
1067
- <div class="main">
1068
- <!-- Status Panel -->
1069
- <div class="panel active" id="panel-status">
1070
- <div class="endpoint"><span class="method">GET</span>/api/status</div>
1071
- <div id="status-content"><div style="color:var(--text-muted)">Loading...</div></div>
1072
- </div>
1073
-
1074
- <!-- Run Panel -->
1075
- <div class="panel" id="panel-run">
1076
- <div class="endpoint"><span class="method">POST</span>/api/run</div>
1077
-
1078
- <div class="section-header">Headers</div>
1079
- <div class="collapsible">
1080
- <div class="header-row">
1081
- <span class="label">X-Session-Id</span>
1082
- <input type="text" id="session-id-run" placeholder="auto-populated from response">
1083
- </div>
1333
+ <div class="app">
1334
+ <header class="header">
1335
+ <div class="header-left">
1336
+ <span class="logo">\u{1F9A6}</span>
1337
+ <span class="logo-text">otterly</span>
1338
+ <span class="version-badge">v0.3.1</span>
1084
1339
  </div>
1085
-
1086
- <div class="mb-8" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">Request Body</div>
1087
- <textarea id="body-run"></textarea>
1088
-
1089
- <div class="btn-row">
1090
- <button class="btn btn-primary" id="send-run">Send</button>
1091
- <button class="btn btn-secondary btn-cancel">Cancel</button>
1092
- </div>
1093
-
1094
- <div class="response-bar" id="res-bar-run"></div>
1095
- <div class="response-tabs">
1096
- <button class="response-tab active" data-mode="formatted">Formatted</button>
1097
- <button class="response-tab" data-mode="raw">Raw</button>
1340
+ <div class="header-center">
1341
+ <nav class="nav">
1342
+ <button class="nav-item active" data-tab="status">Status</button>
1343
+ <button class="nav-item" data-tab="run">Run</button>
1344
+ <button class="nav-item" data-tab="stream">Stream</button>
1345
+ <button class="nav-item" data-tab="chat">Chat</button>
1346
+ <button class="nav-item" data-tab="ws">WebSocket</button>
1347
+ </nav>
1098
1348
  </div>
1099
- <div class="response-body">
1100
- <pre id="res-formatted-run"></pre>
1101
- <pre id="res-raw-run" class="hidden"></pre>
1102
- </div>
1103
- </div>
1104
-
1105
- <!-- Stream Panel -->
1106
- <div class="panel" id="panel-stream">
1107
- <div class="endpoint"><span class="method">POST</span>/api/stream</div>
1108
-
1109
- <div class="section-header">Headers</div>
1110
- <div class="collapsible">
1111
- <div class="header-row">
1112
- <span class="label">X-Session-Id</span>
1113
- <input type="text" id="session-id-stream" placeholder="auto-populated from response">
1349
+ <div class="header-right">
1350
+ <div class="key-wrapper">
1351
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
1352
+ <input type="password" id="api-key" class="key-input" placeholder="API Key">
1114
1353
  </div>
1115
1354
  </div>
1116
-
1117
- <div class="mb-8" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">Request Body</div>
1118
- <textarea id="body-stream"></textarea>
1119
-
1120
- <div class="btn-row">
1121
- <button class="btn btn-primary" id="send-stream">Send</button>
1122
- <button class="btn btn-secondary btn-cancel">Cancel</button>
1123
- <button class="btn btn-secondary" id="stream-raw-toggle">Raw</button>
1124
- </div>
1125
-
1126
- <div class="stream-output hidden" id="stream-output">
1127
- <div id="stream-text"></div>
1128
- <pre id="stream-raw" class="hidden"></pre>
1129
- <button class="jump-bottom">Jump to bottom</button>
1130
- </div>
1131
- <div class="summary-bar hidden" id="stream-summary"></div>
1132
- </div>
1133
-
1134
- <!-- Chat (OpenAI) Panel -->
1135
- <div class="panel" id="panel-chat">
1136
- <div class="endpoint"><span class="method">POST</span>/v1/chat/completions</div>
1137
-
1138
- <div class="section-header">Headers</div>
1139
- <div class="collapsible">
1140
- <div class="header-row">
1141
- <span class="label">X-Session-Id</span>
1142
- <input type="text" id="session-id-chat" placeholder="auto-populated from response">
1355
+ </header>
1356
+
1357
+ <div class="content">
1358
+ <!-- ── Status ── -->
1359
+ <div class="panel active" id="panel-status">
1360
+ <div class="status-panel" id="status-content">
1361
+ <div class="status-inner">
1362
+ <div class="status-hero">
1363
+ <div class="skeleton" style="width:10px;height:10px;border-radius:50%"></div>
1364
+ <div><div class="skeleton" style="width:180px;height:18px;margin-bottom:4px"></div><div class="skeleton" style="width:60px;height:14px"></div></div>
1365
+ </div>
1366
+ <div class="skeleton" style="width:100%;height:100px;border-radius:8px"></div>
1367
+ </div>
1143
1368
  </div>
1144
1369
  </div>
1145
1370
 
1146
- <div class="mb-8" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">Request Body</div>
1147
- <textarea id="body-chat"></textarea>
1148
-
1149
- <div class="btn-row">
1150
- <button class="btn btn-primary" id="send-chat">Send</button>
1151
- <button class="btn btn-secondary btn-cancel">Cancel</button>
1371
+ <!-- ── Run ── -->
1372
+ <div class="panel" id="panel-run">
1373
+ <div class="split-pane">
1374
+ <div class="pane">
1375
+ <div class="pane-head">
1376
+ <span class="method-badge method-post">POST</span>
1377
+ <span class="endpoint-path">/api/run</span>
1378
+ </div>
1379
+ <div class="pane-body">
1380
+ <button class="section-toggle" type="button">
1381
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
1382
+ Headers
1383
+ </button>
1384
+ <div class="collapsible">
1385
+ <div class="field-row">
1386
+ <span class="label-inline">X-Session-Id</span>
1387
+ <input type="text" id="session-id-run" placeholder="auto from response">
1388
+ </div>
1389
+ </div>
1390
+ <div class="field-label">Request body</div>
1391
+ <textarea id="body-run"></textarea>
1392
+ <div class="actions">
1393
+ <button class="btn btn-primary" id="send-run">
1394
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
1395
+ Send
1396
+ </button>
1397
+ <button class="btn btn-ghost btn-cancel">Cancel</button>
1398
+ </div>
1399
+ </div>
1400
+ </div>
1401
+ <div class="pane">
1402
+ <div class="pane-head">
1403
+ <span style="font-size:13px;font-weight:500;color:var(--text-1)">Response</span>
1404
+ <div class="pane-head-right">
1405
+ <div id="res-status-run" class="res-status"></div>
1406
+ <div class="mode-toggle">
1407
+ <button class="active" data-mode="formatted">Formatted</button>
1408
+ <button data-mode="raw">Raw</button>
1409
+ </div>
1410
+ </div>
1411
+ </div>
1412
+ <div class="pane-body">
1413
+ <div id="res-empty-run" class="code-output empty-state">Send a request to see the response</div>
1414
+ <pre id="res-formatted-run" class="code-output hidden response-formatted"></pre>
1415
+ <pre id="res-raw-run" class="code-output hidden response-raw"></pre>
1416
+ </div>
1417
+ </div>
1418
+ </div>
1152
1419
  </div>
1153
1420
 
1154
- <!-- Standard (non-streaming) response -->
1155
- <div id="chat-response-standard">
1156
- <div class="response-bar" id="res-bar-chat"></div>
1157
- <div class="response-tabs">
1158
- <button class="response-tab active" data-mode="formatted">Formatted</button>
1159
- <button class="response-tab" data-mode="raw">Raw</button>
1160
- </div>
1161
- <div class="response-body">
1162
- <pre id="res-formatted-chat"></pre>
1163
- <pre id="res-raw-chat" class="hidden"></pre>
1421
+ <!-- ── Stream ── -->
1422
+ <div class="panel" id="panel-stream">
1423
+ <div class="split-pane">
1424
+ <div class="pane">
1425
+ <div class="pane-head">
1426
+ <span class="method-badge method-post">POST</span>
1427
+ <span class="endpoint-path">/api/stream</span>
1428
+ </div>
1429
+ <div class="pane-body">
1430
+ <button class="section-toggle" type="button">
1431
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
1432
+ Headers
1433
+ </button>
1434
+ <div class="collapsible">
1435
+ <div class="field-row">
1436
+ <span class="label-inline">X-Session-Id</span>
1437
+ <input type="text" id="session-id-stream" placeholder="auto from response">
1438
+ </div>
1439
+ </div>
1440
+ <div class="field-label">Request body</div>
1441
+ <textarea id="body-stream"></textarea>
1442
+ <div class="actions">
1443
+ <button class="btn btn-primary" id="send-stream">
1444
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
1445
+ Send
1446
+ </button>
1447
+ <button class="btn btn-ghost btn-cancel">Cancel</button>
1448
+ <button class="btn btn-ghost btn-sm" id="stream-raw-toggle">Raw</button>
1449
+ </div>
1450
+ </div>
1451
+ </div>
1452
+ <div class="pane">
1453
+ <div class="pane-head">
1454
+ <span style="font-size:13px;font-weight:500;color:var(--text-1)">Stream output</span>
1455
+ </div>
1456
+ <div class="pane-body">
1457
+ <div class="stream-area hidden" id="stream-output">
1458
+ <div id="stream-text"></div>
1459
+ <pre id="stream-raw" class="hidden"></pre>
1460
+ <button class="jump-btn">Jump to bottom</button>
1461
+ </div>
1462
+ <div id="stream-empty" class="code-output empty-state">Send a request to start streaming</div>
1463
+ <div class="summary-bar hidden" id="stream-summary"></div>
1464
+ </div>
1465
+ </div>
1164
1466
  </div>
1165
1467
  </div>
1166
1468
 
1167
- <!-- Streaming response -->
1168
- <div class="stream-output hidden" id="chat-stream-output">
1169
- <div id="chat-stream-text"></div>
1170
- <button class="jump-bottom">Jump to bottom</button>
1469
+ <!-- ── Chat ── -->
1470
+ <div class="panel" id="panel-chat">
1471
+ <div class="split-pane">
1472
+ <div class="pane">
1473
+ <div class="pane-head">
1474
+ <span class="method-badge method-post">POST</span>
1475
+ <span class="endpoint-path">/v1/chat/completions</span>
1476
+ </div>
1477
+ <div class="pane-body">
1478
+ <button class="section-toggle" type="button">
1479
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
1480
+ Headers
1481
+ </button>
1482
+ <div class="collapsible">
1483
+ <div class="field-row">
1484
+ <span class="label-inline">X-Session-Id</span>
1485
+ <input type="text" id="session-id-chat" placeholder="auto from response">
1486
+ </div>
1487
+ </div>
1488
+ <div class="field-label">Request body</div>
1489
+ <textarea id="body-chat"></textarea>
1490
+ <div class="actions">
1491
+ <button class="btn btn-primary" id="send-chat">
1492
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
1493
+ Send
1494
+ </button>
1495
+ <button class="btn btn-ghost btn-cancel">Cancel</button>
1496
+ </div>
1497
+ </div>
1498
+ </div>
1499
+ <div class="pane">
1500
+ <div class="pane-head">
1501
+ <span style="font-size:13px;font-weight:500;color:var(--text-1)">Response</span>
1502
+ <div class="pane-head-right">
1503
+ <div id="res-status-chat" class="res-status"></div>
1504
+ <div class="mode-toggle">
1505
+ <button class="active" data-mode="formatted">Formatted</button>
1506
+ <button data-mode="raw">Raw</button>
1507
+ </div>
1508
+ </div>
1509
+ </div>
1510
+ <div class="pane-body">
1511
+ <div id="chat-response-standard">
1512
+ <div id="res-empty-chat" class="code-output empty-state">Send a request to see the response</div>
1513
+ <pre id="res-formatted-chat" class="code-output hidden response-formatted"></pre>
1514
+ <pre id="res-raw-chat" class="code-output hidden response-raw"></pre>
1515
+ </div>
1516
+ <div class="stream-area hidden" id="chat-stream-output">
1517
+ <div id="chat-stream-text"></div>
1518
+ <button class="jump-btn">Jump to bottom</button>
1519
+ </div>
1520
+ <div class="summary-bar hidden" id="chat-stream-summary"></div>
1521
+ </div>
1522
+ </div>
1523
+ </div>
1171
1524
  </div>
1172
- <div class="summary-bar hidden" id="chat-stream-summary"></div>
1173
- </div>
1174
1525
 
1175
- <!-- WebSocket Panel -->
1176
- <div class="panel" id="panel-ws">
1177
- <div class="endpoint"><span class="method" style="background:var(--success)">WS</span>/ws</div>
1178
-
1179
- <div class="ws-controls">
1180
- <span class="ws-status" id="ws-dot"></span>
1181
- <button class="btn btn-primary" id="ws-connect" style="padding:6px 14px;">Connect</button>
1182
- <button class="btn btn-secondary" id="ws-disconnect" disabled style="padding:6px 14px;">Disconnect</button>
1526
+ <!-- ── WebSocket ── -->
1527
+ <div class="panel" id="panel-ws">
1528
+ <div class="split-pane">
1529
+ <div class="pane">
1530
+ <div class="pane-head">
1531
+ <span class="method-badge method-ws">WS</span>
1532
+ <span class="endpoint-path">/ws</span>
1533
+ </div>
1534
+ <div class="pane-body">
1535
+ <div class="ws-controls">
1536
+ <span class="ws-dot" id="ws-dot"></span>
1537
+ <span class="ws-label" id="ws-label">Disconnected</span>
1538
+ <div style="margin-left:auto;display:flex;gap:6px">
1539
+ <button class="btn btn-primary btn-sm" id="ws-connect">Connect</button>
1540
+ <button class="btn btn-ghost btn-sm" id="ws-disconnect" disabled>Disconnect</button>
1541
+ </div>
1542
+ </div>
1543
+ <div class="field-label mt-12">Message</div>
1544
+ <textarea id="ws-input" style="min-height:80px"></textarea>
1545
+ <div class="actions">
1546
+ <button class="btn btn-primary" id="ws-send" disabled>
1547
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
1548
+ Send
1549
+ </button>
1550
+ </div>
1551
+ </div>
1552
+ </div>
1553
+ <div class="pane">
1554
+ <div class="pane-head">
1555
+ <span style="font-size:13px;font-weight:500;color:var(--text-1)">Messages</span>
1556
+ </div>
1557
+ <div class="pane-body">
1558
+ <div class="ws-log" id="ws-log" style="flex:1"></div>
1559
+ </div>
1560
+ </div>
1561
+ </div>
1183
1562
  </div>
1184
1563
 
1185
- <div class="ws-log" id="ws-log"></div>
1186
-
1187
- <div class="ws-input-row">
1188
- <textarea id="ws-input"></textarea>
1189
- <button class="btn btn-primary" id="ws-send" disabled style="align-self:flex-end;">Send</button>
1190
- </div>
1191
1564
  </div>
1192
1565
  </div>
1193
1566
  `;
1194
1567
  }
1195
1568
  export function getPlaygroundHtml(port) {
1196
- // Fill in templates via inline script
1197
1569
  const templateScript = `
1198
1570
  <script>
1199
1571
  document.addEventListener('DOMContentLoaded', function() {
1200
- var TEMPLATES = {
1572
+ var T = {
1201
1573
  run: ${JSON.stringify(JSON.stringify({ prompt: "What is 2 + 2?", options: { maxTurns: 1 } }, null, 2))},
1202
1574
  stream: ${JSON.stringify(JSON.stringify({ prompt: "Write a haiku about coding", options: { maxTurns: 1 } }, null, 2))},
1203
1575
  chat: ${JSON.stringify(JSON.stringify({ model: "claude-sonnet-4-20250514", messages: [{ role: "user", content: "Hello! What can you do?" }], stream: false }, null, 2))},
1204
1576
  ws: ${JSON.stringify(JSON.stringify({ type: "start", prompt: "Hello from WebSocket" }, null, 2))}
1205
1577
  };
1206
1578
  var el;
1207
- el = document.getElementById('body-run'); if (el) el.value = TEMPLATES.run;
1208
- el = document.getElementById('body-stream'); if (el) el.value = TEMPLATES.stream;
1209
- el = document.getElementById('body-chat'); if (el) el.value = TEMPLATES.chat;
1210
- el = document.getElementById('ws-input'); if (el) el.value = TEMPLATES.ws;
1579
+ el = document.getElementById('body-run'); if (el) el.value = T.run;
1580
+ el = document.getElementById('body-stream'); if (el) el.value = T.stream;
1581
+ el = document.getElementById('body-chat'); if (el) el.value = T.chat;
1582
+ el = document.getElementById('ws-input'); if (el) el.value = T.ws;
1583
+
1584
+ // Hide empty states when content appears
1585
+ document.querySelectorAll('.code-output.empty-state').forEach(function(empty) {
1586
+ var observer = new MutationObserver(function() {
1587
+ var panel = empty.closest('.pane-body');
1588
+ if (!panel) return;
1589
+ var formatted = panel.querySelector('.response-formatted');
1590
+ var raw = panel.querySelector('.response-raw');
1591
+ if ((formatted && formatted.innerHTML) || (raw && raw.textContent)) {
1592
+ empty.classList.add('hidden');
1593
+ if (formatted) formatted.classList.remove('hidden');
1594
+ }
1595
+ });
1596
+ var panel = empty.closest('.pane-body');
1597
+ if (panel) observer.observe(panel, { childList: true, subtree: true, characterData: true });
1598
+ });
1599
+
1600
+ // Hide stream empty state when streaming starts
1601
+ var streamEmpty = document.getElementById('stream-empty');
1602
+ var streamOutput = document.getElementById('stream-output');
1603
+ if (streamEmpty && streamOutput) {
1604
+ new MutationObserver(function() {
1605
+ if (!streamOutput.classList.contains('hidden')) {
1606
+ streamEmpty.classList.add('hidden');
1607
+ }
1608
+ }).observe(streamOutput, { attributes: true, attributeFilter: ['class'] });
1609
+ }
1211
1610
  });
1212
1611
  </script>`;
1213
1612
  return `<!DOCTYPE html>