ninja-terminals 2.3.1 → 2.3.2

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,463 @@
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.0">
6
+ <title>Ninja Log Viewer</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #0a0a0a;
12
+ --panel-bg: #111;
13
+ --border: #333;
14
+ --text: #e0e0e0;
15
+ --dim: #666;
16
+ --status-done: #4ade80;
17
+ --status-working: #facc15;
18
+ --status-blocked: #f87171;
19
+ --status-error: #ef4444;
20
+ --status-idle: #94a3b8;
21
+ }
22
+
23
+ html, body {
24
+ height: 100%;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ font-family: 'SF Mono', 'Monaco', 'Menlo', monospace;
28
+ font-size: 12px;
29
+ }
30
+
31
+ .container {
32
+ display: flex;
33
+ flex-direction: column;
34
+ height: 100vh;
35
+ }
36
+
37
+ /* Status Bar */
38
+ .status-bar {
39
+ display: flex;
40
+ gap: 8px;
41
+ padding: 8px 12px;
42
+ background: #1a1a1a;
43
+ border-bottom: 1px solid var(--border);
44
+ flex-shrink: 0;
45
+ overflow-x: auto;
46
+ }
47
+
48
+ .status-chip {
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 6px;
52
+ padding: 4px 10px;
53
+ border-radius: 4px;
54
+ background: var(--panel-bg);
55
+ border: 1px solid var(--border);
56
+ font-size: 11px;
57
+ white-space: nowrap;
58
+ }
59
+
60
+ .status-chip .dot {
61
+ width: 8px;
62
+ height: 8px;
63
+ border-radius: 50%;
64
+ background: var(--status-idle);
65
+ }
66
+
67
+ .status-chip.done .dot { background: var(--status-done); }
68
+ .status-chip.working .dot { background: var(--status-working); animation: pulse 1s infinite; }
69
+ .status-chip.blocked .dot { background: var(--status-blocked); }
70
+ .status-chip.error .dot { background: var(--status-error); }
71
+ .status-chip.idle .dot { background: var(--status-idle); }
72
+
73
+ @keyframes pulse {
74
+ 0%, 100% { opacity: 1; }
75
+ 50% { opacity: 0.5; }
76
+ }
77
+
78
+ .status-chip .label {
79
+ font-weight: 600;
80
+ color: var(--text);
81
+ }
82
+
83
+ .status-chip .message {
84
+ color: var(--dim);
85
+ max-width: 200px;
86
+ overflow: hidden;
87
+ text-overflow: ellipsis;
88
+ }
89
+
90
+ /* Grid */
91
+ .grid {
92
+ display: grid;
93
+ grid-template-columns: 1fr 1fr;
94
+ grid-template-rows: 1fr 1fr;
95
+ gap: 1px;
96
+ background: var(--border);
97
+ flex: 1;
98
+ min-height: 0;
99
+ }
100
+
101
+ .pane {
102
+ background: var(--panel-bg);
103
+ display: flex;
104
+ flex-direction: column;
105
+ min-height: 0;
106
+ }
107
+
108
+ .pane-header {
109
+ display: flex;
110
+ justify-content: space-between;
111
+ align-items: center;
112
+ padding: 6px 10px;
113
+ background: #1a1a1a;
114
+ border-bottom: 1px solid var(--border);
115
+ flex-shrink: 0;
116
+ }
117
+
118
+ .pane-title {
119
+ font-weight: 600;
120
+ font-size: 11px;
121
+ text-transform: uppercase;
122
+ letter-spacing: 0.5px;
123
+ }
124
+
125
+ .pane-status {
126
+ font-size: 10px;
127
+ padding: 2px 6px;
128
+ border-radius: 3px;
129
+ background: var(--bg);
130
+ }
131
+
132
+ .pane-content {
133
+ flex: 1;
134
+ overflow-y: auto;
135
+ padding: 8px;
136
+ font-size: 11px;
137
+ line-height: 1.4;
138
+ white-space: pre-wrap;
139
+ word-break: break-word;
140
+ }
141
+
142
+ .pane-content::-webkit-scrollbar {
143
+ width: 6px;
144
+ }
145
+
146
+ .pane-content::-webkit-scrollbar-track {
147
+ background: var(--bg);
148
+ }
149
+
150
+ .pane-content::-webkit-scrollbar-thumb {
151
+ background: var(--border);
152
+ border-radius: 3px;
153
+ }
154
+
155
+ /* Log line highlighting */
156
+ .log-line { margin: 1px 0; }
157
+ .log-line.status { color: var(--status-done); font-weight: 600; }
158
+ .log-line.blocked { color: var(--status-blocked); font-weight: 600; }
159
+ .log-line.error { color: var(--status-error); font-weight: 600; }
160
+ .log-line.progress { color: var(--status-working); }
161
+
162
+ /* Empty state */
163
+ .empty {
164
+ display: flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ height: 100%;
168
+ color: var(--dim);
169
+ font-style: italic;
170
+ }
171
+
172
+ /* Connection status */
173
+ .connection-status {
174
+ position: fixed;
175
+ bottom: 12px;
176
+ right: 12px;
177
+ padding: 6px 12px;
178
+ background: var(--panel-bg);
179
+ border: 1px solid var(--border);
180
+ border-radius: 4px;
181
+ font-size: 10px;
182
+ color: var(--dim);
183
+ }
184
+
185
+ .connection-status.connected { border-color: var(--status-done); color: var(--status-done); }
186
+ .connection-status.disconnected { border-color: var(--status-error); color: var(--status-error); }
187
+ </style>
188
+ </head>
189
+ <body>
190
+ <div class="container">
191
+ <div class="status-bar" id="status-bar">
192
+ <div class="status-chip idle">
193
+ <span class="dot"></span>
194
+ <span class="label">Connecting...</span>
195
+ </div>
196
+ </div>
197
+
198
+ <div class="grid" id="grid">
199
+ <div class="pane" data-slot="0">
200
+ <div class="pane-header">
201
+ <span class="pane-title">Terminal 1</span>
202
+ <span class="pane-status">—</span>
203
+ </div>
204
+ <div class="pane-content"><div class="empty">Waiting for terminal...</div></div>
205
+ </div>
206
+ <div class="pane" data-slot="1">
207
+ <div class="pane-header">
208
+ <span class="pane-title">Terminal 2</span>
209
+ <span class="pane-status">—</span>
210
+ </div>
211
+ <div class="pane-content"><div class="empty">Waiting for terminal...</div></div>
212
+ </div>
213
+ <div class="pane" data-slot="2">
214
+ <div class="pane-header">
215
+ <span class="pane-title">Terminal 3</span>
216
+ <span class="pane-status">—</span>
217
+ </div>
218
+ <div class="pane-content"><div class="empty">Waiting for terminal...</div></div>
219
+ </div>
220
+ <div class="pane" data-slot="3">
221
+ <div class="pane-header">
222
+ <span class="pane-title">Terminal 4</span>
223
+ <span class="pane-status">—</span>
224
+ </div>
225
+ <div class="pane-content"><div class="empty">Waiting for terminal...</div></div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ <div class="connection-status" id="conn-status">Connecting...</div>
231
+
232
+ <script>
233
+ const MAX_LINES = 500;
234
+ const TOKEN_KEY = 'ninja_token';
235
+ const terminals = new Map();
236
+ const panes = document.querySelectorAll('.pane');
237
+ const statusBar = document.getElementById('status-bar');
238
+ const connStatus = document.getElementById('conn-status');
239
+
240
+ // Get auth token from localStorage (shared with main UI)
241
+ function getToken() {
242
+ return localStorage.getItem(TOKEN_KEY);
243
+ }
244
+
245
+ // Auth headers for fetch
246
+ function authHeaders() {
247
+ const token = getToken();
248
+ return token ? { 'Authorization': `Bearer ${token}` } : {};
249
+ }
250
+
251
+ // ANSI escape stripper
252
+ function stripAnsi(str) {
253
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
254
+ }
255
+
256
+ // Classify log line
257
+ function classifyLine(line) {
258
+ const stripped = line.toUpperCase();
259
+ if (stripped.includes('STATUS: DONE') || stripped.includes('[DONE]')) return 'status';
260
+ if (stripped.includes('STATUS: BLOCKED') || stripped.includes('[BLOCKED]')) return 'blocked';
261
+ if (stripped.includes('STATUS: ERROR') || stripped.includes('[ERROR]')) return 'error';
262
+ if (stripped.includes('PROGRESS:')) return 'progress';
263
+ return '';
264
+ }
265
+
266
+ // Append log line to pane
267
+ function appendLog(pane, text) {
268
+ const content = pane.querySelector('.pane-content');
269
+ const empty = content.querySelector('.empty');
270
+ if (empty) empty.remove();
271
+
272
+ const lines = stripAnsi(text).split('\n');
273
+ for (const line of lines) {
274
+ if (!line.trim()) continue;
275
+ const div = document.createElement('div');
276
+ div.className = 'log-line ' + classifyLine(line);
277
+ div.textContent = line;
278
+ content.appendChild(div);
279
+ }
280
+
281
+ // Prune old lines
282
+ while (content.children.length > MAX_LINES) {
283
+ content.removeChild(content.firstChild);
284
+ }
285
+
286
+ // Auto-scroll
287
+ content.scrollTop = content.scrollHeight;
288
+ }
289
+
290
+ // Update status bar
291
+ function updateStatusBar() {
292
+ statusBar.innerHTML = '';
293
+ for (const [id, t] of terminals) {
294
+ const chip = document.createElement('div');
295
+ chip.className = 'status-chip ' + (t.status || 'idle');
296
+ chip.innerHTML = `
297
+ <span class="dot"></span>
298
+ <span class="label">${t.label || 'T' + id}</span>
299
+ <span class="message">${t.taskMessage || t.status || 'idle'}</span>
300
+ `;
301
+ statusBar.appendChild(chip);
302
+ }
303
+
304
+ if (terminals.size === 0) {
305
+ statusBar.innerHTML = '<div class="status-chip idle"><span class="dot"></span><span class="label">No terminals</span></div>';
306
+ }
307
+ }
308
+
309
+ // Connect WebSocket to terminal
310
+ function connectTerminal(id, slot) {
311
+ const pane = panes[slot];
312
+ if (!pane) return;
313
+
314
+ const token = getToken();
315
+ const wsUrl = token
316
+ ? `ws://${location.host}/ws/${id}?token=${encodeURIComponent(token)}`
317
+ : `ws://${location.host}/ws/${id}`;
318
+ const ws = new WebSocket(wsUrl);
319
+
320
+ ws.onopen = () => {
321
+ console.log(`Connected to terminal ${id}`);
322
+ const t = terminals.get(id) || {};
323
+ t.ws = ws;
324
+ t.slot = slot;
325
+ terminals.set(id, t);
326
+ };
327
+
328
+ ws.onmessage = (e) => {
329
+ appendLog(pane, e.data);
330
+ };
331
+
332
+ ws.onclose = () => {
333
+ console.log(`Disconnected from terminal ${id}`);
334
+ const t = terminals.get(id);
335
+ if (t) t.ws = null;
336
+ };
337
+
338
+ ws.onerror = (err) => {
339
+ console.error(`WebSocket error for terminal ${id}:`, err);
340
+ };
341
+ }
342
+
343
+ // Connect SSE for status updates
344
+ function connectSSE() {
345
+ const es = new EventSource('/api/events');
346
+
347
+ es.onopen = () => {
348
+ connStatus.textContent = 'Connected';
349
+ connStatus.className = 'connection-status connected';
350
+ };
351
+
352
+ es.onerror = () => {
353
+ connStatus.textContent = 'Disconnected';
354
+ connStatus.className = 'connection-status disconnected';
355
+ };
356
+
357
+ es.addEventListener('status_change', (e) => {
358
+ const data = JSON.parse(e.data);
359
+ const t = terminals.get(data.id) || { label: data.terminal };
360
+ t.status = data.to;
361
+ terminals.set(data.id, t);
362
+ updateStatusBar();
363
+
364
+ // Update pane status
365
+ const pane = panes[t.slot];
366
+ if (pane) {
367
+ pane.querySelector('.pane-status').textContent = data.to;
368
+ }
369
+ });
370
+
371
+ es.addEventListener('task_status_change', (e) => {
372
+ const data = JSON.parse(e.data);
373
+ const t = terminals.get(data.id) || { label: data.terminal };
374
+ t.status = data.taskStatus?.state || data.processStatus;
375
+ t.taskMessage = data.taskStatus?.message || '';
376
+ terminals.set(data.id, t);
377
+ updateStatusBar();
378
+ });
379
+ }
380
+
381
+ // Fetch terminals and connect
382
+ async function init() {
383
+ const token = getToken();
384
+ if (!token) {
385
+ connStatus.textContent = 'No auth token - login at main UI first';
386
+ connStatus.className = 'connection-status disconnected';
387
+ return;
388
+ }
389
+
390
+ try {
391
+ const res = await fetch('/api/terminals', { headers: authHeaders() });
392
+ if (!res.ok) {
393
+ throw new Error(`API returned ${res.status}`);
394
+ }
395
+ const data = await res.json();
396
+
397
+ data.forEach((t, i) => {
398
+ if (i >= 4) return; // Max 4 panes
399
+
400
+ terminals.set(t.id, {
401
+ label: t.label,
402
+ status: t.status,
403
+ slot: i
404
+ });
405
+
406
+ // Update pane header
407
+ const pane = panes[i];
408
+ pane.querySelector('.pane-title').textContent = t.label || `Terminal ${t.id}`;
409
+ pane.querySelector('.pane-status').textContent = t.status;
410
+
411
+ // Connect WebSocket
412
+ connectTerminal(t.id, i);
413
+ });
414
+
415
+ updateStatusBar();
416
+ connectSSE();
417
+
418
+ } catch (err) {
419
+ console.error('Failed to fetch terminals:', err);
420
+ connStatus.textContent = 'Failed to connect';
421
+ connStatus.className = 'connection-status disconnected';
422
+
423
+ // Retry in 3s
424
+ setTimeout(init, 3000);
425
+ }
426
+ }
427
+
428
+ // Poll for new terminals
429
+ setInterval(async () => {
430
+ if (!getToken()) return;
431
+ try {
432
+ const res = await fetch('/api/terminals', { headers: authHeaders() });
433
+ if (!res.ok) return;
434
+ const data = await res.json();
435
+
436
+ data.forEach((t, i) => {
437
+ if (i >= 4) return;
438
+
439
+ if (!terminals.has(t.id)) {
440
+ terminals.set(t.id, {
441
+ label: t.label,
442
+ status: t.status,
443
+ slot: i
444
+ });
445
+
446
+ const pane = panes[i];
447
+ pane.querySelector('.pane-title').textContent = t.label || `Terminal ${t.id}`;
448
+ pane.querySelector('.pane-status').textContent = t.status;
449
+ pane.querySelector('.pane-content').innerHTML = '';
450
+
451
+ connectTerminal(t.id, i);
452
+ updateStatusBar();
453
+ }
454
+ });
455
+ } catch (err) {
456
+ // Ignore polling errors
457
+ }
458
+ }, 5000);
459
+
460
+ init();
461
+ </script>
462
+ </body>
463
+ </html>
package/public/style.css CHANGED
@@ -330,6 +330,25 @@ main {
330
330
  animation: flash-green 0.5s ease-out;
331
331
  }
332
332
 
333
+ .terminal-pane.drag-over {
334
+ border-color: var(--accent) !important;
335
+ box-shadow: inset 0 0 30px rgba(232, 169, 23, 0.15);
336
+ }
337
+
338
+ .terminal-pane.drag-over::after {
339
+ content: 'Drop file here';
340
+ position: absolute;
341
+ top: 50%;
342
+ left: 50%;
343
+ transform: translate(-50%, -50%);
344
+ color: var(--accent);
345
+ font-size: 1.2rem;
346
+ font-weight: 600;
347
+ pointer-events: none;
348
+ z-index: 100;
349
+ text-shadow: 0 0 10px rgba(0, 0, 0, 0.8);
350
+ }
351
+
333
352
  /* ── Pane Header ────────────────────────────── */
334
353
 
335
354
  .pane-header {
@@ -424,6 +443,51 @@ main {
424
443
  .state-text.starting { color: var(--state-idle); }
425
444
  .state-text.exited { color: var(--state-error); opacity: 0.7; }
426
445
 
446
+ /* Task status indicator (semantic, separate from process status) */
447
+ .pane-task-status {
448
+ display: flex;
449
+ align-items: center;
450
+ gap: 4px;
451
+ flex-shrink: 0;
452
+ margin-left: 8px;
453
+ padding: 2px 6px;
454
+ border-radius: 3px;
455
+ background: rgba(0,0,0,0.3);
456
+ }
457
+
458
+ .task-icon {
459
+ font-size: 10px;
460
+ flex-shrink: 0;
461
+ }
462
+
463
+ .task-icon.pending { color: var(--state-idle); }
464
+ .task-icon.running { color: var(--state-working); }
465
+ .task-icon.done { color: var(--state-done); }
466
+ .task-icon.blocked { color: var(--state-blocked); }
467
+ .task-icon.error { color: var(--state-error); }
468
+ .task-icon.unknown { color: var(--text-secondary); }
469
+
470
+ .task-text {
471
+ font-family: 'Space Grotesk', sans-serif;
472
+ font-size: 10px;
473
+ font-weight: 700;
474
+ text-transform: uppercase;
475
+ letter-spacing: 0.5px;
476
+ }
477
+
478
+ .task-text.pending { color: var(--state-idle); }
479
+ .task-text.running { color: var(--state-working); }
480
+ .task-text.done { color: var(--state-done); }
481
+ .task-text.blocked { color: var(--state-blocked); }
482
+ .task-text.error { color: var(--state-error); }
483
+ .task-text.unknown { color: var(--text-secondary); }
484
+
485
+ /* Task status pane borders (for orchestration visibility) */
486
+ .terminal-pane.task-done { border-color: var(--state-done); }
487
+ .terminal-pane.task-blocked { border-color: var(--state-blocked); }
488
+ .terminal-pane.task-error { border-color: var(--state-error); }
489
+ .terminal-pane.task-running { border-left: 3px solid var(--state-working); }
490
+
427
491
  .pane-elapsed {
428
492
  font-size: 10px;
429
493
  color: var(--text-dim);