nikcli-remote 1.0.1 → 1.0.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.
package/src/web-client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * @nikcli/remote - Mobile Web Client
3
- * Touch-friendly terminal interface for mobile devices
2
+ * @nikcli/remote - Ghostty-web Style Terminal Client
3
+ * Uses hterm for full terminal emulation
4
4
  */
5
5
 
6
6
  export function getWebClient(): string {
@@ -8,650 +8,271 @@ export function getWebClient(): string {
8
8
  <html lang="en">
9
9
  <head>
10
10
  <meta charset="UTF-8">
11
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
11
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
12
12
  <meta name="apple-mobile-web-app-capable" content="yes">
13
- <meta name="mobile-web-app-capable" content="yes">
14
- <meta name="theme-color" content="#0d1117">
13
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
15
14
  <title>NikCLI Remote</title>
16
15
  <style>
16
+ * { box-sizing: border-box; margin: 0; padding: 0; }
17
17
  :root {
18
- --bg: #0d1117;
18
+ --bg-primary: #0d1117;
19
19
  --bg-secondary: #161b22;
20
- --fg: #e6edf3;
21
- --fg-muted: #8b949e;
22
20
  --accent: #58a6ff;
23
21
  --success: #3fb950;
24
- --warning: #d29922;
25
- --error: #f85149;
26
22
  --border: #30363d;
27
- --font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
28
23
  }
29
-
30
- * {
31
- box-sizing: border-box;
32
- margin: 0;
33
- padding: 0;
34
- -webkit-tap-highlight-color: transparent;
35
- }
36
-
37
- html, body {
38
- height: 100%;
39
- background: var(--bg);
40
- color: var(--fg);
41
- font-family: var(--font-mono);
42
- font-size: 14px;
43
- overflow: hidden;
44
- touch-action: manipulation;
45
- }
46
-
47
- #app {
24
+ html, body { height: 100%; overflow: hidden; touch-action: manipulation; }
25
+ body {
26
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
27
+ background: var(--bg-primary);
28
+ color: #e6edf3;
48
29
  display: flex;
49
30
  flex-direction: column;
50
- height: 100%;
51
- height: 100dvh;
52
- }
53
-
54
- /* Header */
55
- #header {
56
- display: flex;
57
- align-items: center;
58
- justify-content: space-between;
59
- padding: 12px 16px;
60
- background: var(--bg-secondary);
61
- border-bottom: 1px solid var(--border);
62
- flex-shrink: 0;
63
31
  }
64
-
65
- #header h1 {
66
- font-size: 16px;
67
- font-weight: 600;
68
- color: var(--accent);
69
- display: flex;
70
- align-items: center;
71
- gap: 8px;
72
- }
73
-
74
- #header h1::before {
75
- content: '';
76
- width: 10px;
77
- height: 10px;
78
- background: var(--accent);
79
- border-radius: 2px;
80
- }
81
-
82
- #status {
83
- display: flex;
84
- align-items: center;
85
- gap: 6px;
86
- font-size: 12px;
87
- color: var(--fg-muted);
88
- }
89
-
90
- #status-dot {
91
- width: 8px;
92
- height: 8px;
93
- border-radius: 50%;
94
- background: var(--error);
95
- transition: background 0.3s;
96
- }
97
-
98
- #status-dot.connected {
99
- background: var(--success);
100
- }
101
-
102
- #status-dot.connecting {
103
- background: var(--warning);
104
- animation: pulse 1s infinite;
105
- }
106
-
107
- @keyframes pulse {
108
- 0%, 100% { opacity: 1; }
109
- 50% { opacity: 0.5; }
110
- }
111
-
112
- /* Terminal */
113
- #terminal-container {
114
- flex: 1;
115
- overflow: hidden;
116
- position: relative;
117
- }
118
-
119
- #terminal {
120
- height: 100%;
121
- padding: 12px;
122
- overflow-y: auto;
123
- overflow-x: hidden;
124
- font-size: 13px;
125
- line-height: 1.5;
126
- white-space: pre-wrap;
127
- word-break: break-all;
128
- -webkit-overflow-scrolling: touch;
129
- }
130
-
131
- #terminal::-webkit-scrollbar {
132
- width: 6px;
133
- }
134
-
135
- #terminal::-webkit-scrollbar-track {
136
- background: var(--bg);
137
- }
138
-
139
- #terminal::-webkit-scrollbar-thumb {
140
- background: var(--border);
141
- border-radius: 3px;
142
- }
143
-
144
- .cursor {
145
- display: inline-block;
146
- width: 8px;
147
- height: 16px;
148
- background: var(--fg);
149
- animation: blink 1s step-end infinite;
150
- vertical-align: text-bottom;
151
- }
152
-
153
- @keyframes blink {
154
- 50% { opacity: 0; }
155
- }
156
-
157
- /* Notifications */
158
- #notifications {
159
- position: fixed;
160
- top: 60px;
161
- left: 12px;
162
- right: 12px;
163
- z-index: 1000;
164
- pointer-events: none;
165
- }
166
-
167
- .notification {
168
- background: var(--bg-secondary);
169
- border: 1px solid var(--border);
170
- border-radius: 8px;
171
- padding: 12px 16px;
172
- margin-bottom: 8px;
173
- animation: slideIn 0.3s ease;
174
- pointer-events: auto;
175
- box-shadow: 0 4px 12px rgba(0,0,0,0.4);
176
- }
177
-
178
- .notification.success { border-left: 3px solid var(--success); }
179
- .notification.error { border-left: 3px solid var(--error); }
180
- .notification.warning { border-left: 3px solid var(--warning); }
181
- .notification.info { border-left: 3px solid var(--accent); }
182
-
183
- .notification h4 {
184
- font-size: 14px;
185
- font-weight: 600;
186
- margin-bottom: 4px;
187
- }
188
-
189
- .notification p {
190
- font-size: 12px;
191
- color: var(--fg-muted);
192
- }
193
-
194
- @keyframes slideIn {
195
- from { transform: translateY(-20px); opacity: 0; }
196
- to { transform: translateY(0); opacity: 1; }
197
- }
198
-
199
- /* Quick Keys */
200
- #quickkeys {
201
- display: grid;
202
- grid-template-columns: repeat(6, 1fr);
203
- gap: 6px;
204
- padding: 8px 12px;
205
- background: var(--bg-secondary);
206
- border-top: 1px solid var(--border);
207
- flex-shrink: 0;
208
- }
209
-
210
- .qkey {
211
- background: var(--bg);
212
- border: 1px solid var(--border);
213
- border-radius: 6px;
214
- padding: 10px 4px;
215
- color: var(--fg);
216
- font-size: 11px;
217
- font-family: var(--font-mono);
218
- text-align: center;
219
- cursor: pointer;
220
- user-select: none;
221
- transition: background 0.1s, transform 0.1s;
222
- }
223
-
224
- .qkey:active {
225
- background: var(--border);
226
- transform: scale(0.95);
227
- }
228
-
229
- .qkey.wide {
230
- grid-column: span 2;
231
- }
232
-
233
- .qkey.accent {
234
- background: var(--accent);
235
- border-color: var(--accent);
236
- color: #fff;
237
- }
238
-
239
- /* Input */
240
- #input-container {
241
- padding: 12px;
32
+ #terminal { flex: 1; overflow: hidden; background: var(--bg-primary); }
33
+ #input-area {
242
34
  background: var(--bg-secondary);
243
35
  border-top: 1px solid var(--border);
36
+ padding: 12px 16px;
244
37
  flex-shrink: 0;
38
+ padding-bottom: env(safe-area-inset-bottom, 12px);
245
39
  }
246
-
247
- #input-row {
248
- display: flex;
249
- gap: 8px;
250
- }
251
-
252
- #input {
40
+ .input-row { display: flex; gap: 8px; align-items: center; }
41
+ .prompt { color: var(--success); font-family: 'SF Mono', Monaco, monospace; font-size: 14px; white-space: nowrap; }
42
+ #cmd-input {
253
43
  flex: 1;
254
- background: var(--bg);
44
+ background: #21262d;
255
45
  border: 1px solid var(--border);
256
- border-radius: 8px;
257
- padding: 12px 14px;
258
- color: var(--fg);
259
- font-family: var(--font-mono);
46
+ border-radius: 12px;
47
+ padding: 12px 16px;
48
+ color: #e6edf3;
260
49
  font-size: 16px;
50
+ font-family: 'SF Mono', Monaco, monospace;
261
51
  outline: none;
262
- transition: border-color 0.2s;
52
+ -webkit-appearance: none;
263
53
  }
264
-
265
- #input:focus {
266
- border-color: var(--accent);
267
- }
268
-
269
- #input::placeholder {
270
- color: var(--fg-muted);
271
- }
272
-
273
- #send {
54
+ #cmd-input:focus { border-color: var(--accent); }
55
+ #send-btn {
274
56
  background: var(--accent);
275
- color: #fff;
57
+ color: white;
276
58
  border: none;
277
- border-radius: 8px;
278
- padding: 12px 20px;
279
- font-size: 14px;
59
+ border-radius: 12px;
60
+ padding: 12px 24px;
61
+ font-size: 16px;
280
62
  font-weight: 600;
281
- font-family: var(--font-mono);
282
63
  cursor: pointer;
283
- transition: opacity 0.2s, transform 0.1s;
284
- }
285
-
286
- #send:active {
287
- opacity: 0.8;
288
- transform: scale(0.98);
64
+ -webkit-tap-highlight-color: transparent;
289
65
  }
290
-
291
- /* Auth Screen */
292
- #auth-screen {
293
- position: fixed;
294
- inset: 0;
295
- background: var(--bg);
66
+ #send-btn:active { opacity: 0.7; }
67
+ #status-bar {
68
+ background: var(--bg-secondary);
69
+ border-bottom: 1px solid var(--border);
70
+ padding: 8px 16px;
296
71
  display: flex;
297
- flex-direction: column;
72
+ justify-content: space-between;
298
73
  align-items: center;
299
- justify-content: center;
300
- gap: 20px;
301
- z-index: 2000;
302
- }
303
-
304
- #auth-screen.hidden {
305
- display: none;
306
- }
307
-
308
- .spinner {
309
- width: 40px;
310
- height: 40px;
311
- border: 3px solid var(--border);
312
- border-top-color: var(--accent);
313
- border-radius: 50%;
314
- animation: spin 1s linear infinite;
315
- }
316
-
317
- @keyframes spin {
318
- to { transform: rotate(360deg); }
319
- }
320
-
321
- #auth-screen p {
322
- color: var(--fg-muted);
323
- font-size: 14px;
324
- }
325
-
326
- #auth-screen .error {
327
- color: var(--error);
328
- }
329
-
330
- /* Safe area padding for notched devices */
331
- @supports (padding: env(safe-area-inset-bottom)) {
332
- #input-container {
333
- padding-bottom: calc(12px + env(safe-area-inset-bottom));
334
- }
335
- }
336
-
337
- /* Landscape adjustments */
338
- @media (max-height: 500px) {
339
- #quickkeys {
340
- grid-template-columns: repeat(12, 1fr);
341
- padding: 6px 8px;
342
- }
343
- .qkey {
344
- padding: 8px 2px;
345
- font-size: 10px;
346
- }
347
- #terminal {
348
- font-size: 12px;
349
- }
74
+ font-size: 12px;
75
+ padding-top: env(safe-area-inset-top, 8px);
76
+ }
77
+ .status-row { display: flex; align-items: center; gap: 8px; }
78
+ .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b949e; }
79
+ .status-dot.connected { background: var(--success); box-shadow: 0 0 8px var(--success); }
80
+ .status-dot.connecting { background: var(--accent); animation: pulse 1s infinite; }
81
+ .status-dot.disconnected { background: #f85149; }
82
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
83
+ #auth-overlay {
84
+ position: fixed; inset: 0; background: var(--bg-primary);
85
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
86
+ padding: 20px; z-index: 100;
87
+ }
88
+ #auth-overlay.hidden { display: none; }
89
+ .auth-title { font-size: 28px; font-weight: 700; margin-bottom: 8px; }
90
+ .auth-subtitle { color: #8b949e; font-size: 16px; margin-bottom: 24px; }
91
+ .auth-msg {
92
+ background: var(--bg-secondary); padding: 20px 28px;
93
+ border-radius: 16px; border: 1px solid var(--border); text-align: center;
94
+ }
95
+ .auth-msg.error { color: #f85149; border-color: #f85149; }
96
+ .quick-btns {
97
+ display: flex; gap: 8px; margin-top: 20px; flex-wrap: wrap; justify-content: center;
98
+ }
99
+ .quick-btn {
100
+ background: #21262d; border: 1px solid var(--border); color: #e6edf3;
101
+ padding: 10px 16px; border-radius: 8px; font-size: 14px;
102
+ font-family: 'SF Mono', Monaco, monospace; cursor: pointer;
103
+ }
104
+ .quick-btn:active { background: #30363d; }
105
+ .hint { font-size: 12px; color: #8b949e; margin-top: 12px; }
106
+ @media (max-width: 600px) {
107
+ #input-area { padding: 10px 12px; }
108
+ .quick-btn { padding: 8px 12px; font-size: 12px; }
350
109
  }
351
110
  </style>
352
111
  </head>
353
112
  <body>
354
- <div id="app">
355
- <div id="auth-screen">
356
- <div class="spinner"></div>
357
- <p id="auth-status">Connecting to NikCLI...</p>
113
+ <div id="auth-overlay">
114
+ <div class="auth-title">📱 NikCLI Remote</div>
115
+ <div class="auth-subtitle">Full terminal emulation</div>
116
+ <div id="auth-msg" class="auth-msg">
117
+ <div id="auth-text">Connecting...</div>
358
118
  </div>
359
-
360
- <header id="header">
361
- <h1>NikCLI Remote</h1>
362
- <div id="status">
363
- <span id="status-dot" class="connecting"></span>
364
- <span id="status-text">Connecting</span>
365
- </div>
366
- </header>
367
-
368
- <div id="terminal-container">
369
- <div id="terminal"></div>
370
- </div>
371
-
372
- <div id="notifications"></div>
373
-
374
- <div id="quickkeys">
375
- <button class="qkey" data-key="\\t">Tab</button>
376
- <button class="qkey" data-key="\\x1b[A">↑</button>
377
- <button class="qkey" data-key="\\x1b[B">↓</button>
378
- <button class="qkey" data-key="\\x1b[D">←</button>
379
- <button class="qkey" data-key="\\x1b[C">→</button>
380
- <button class="qkey" data-key="\\x1b">Esc</button>
381
- <button class="qkey" data-key="\\x03">^C</button>
382
- <button class="qkey" data-key="\\x04">^D</button>
383
- <button class="qkey" data-key="\\x1a">^Z</button>
384
- <button class="qkey" data-key="\\x0c">^L</button>
385
- <button class="qkey wide accent" data-key="\\r">Enter ⏎</button>
119
+ <div class="quick-btns">
120
+ <button class="quick-btn" onclick="send('help')">/help</button>
121
+ <button class="quick-btn" onclick="send('ls -la')">ls -la</button>
122
+ <button class="quick-btn" onclick="send('pwd')">pwd</button>
123
+ <button class="quick-btn" onclick="send('whoami')">whoami</button>
124
+ <button class="quick-btn" onclick="send('clear')">clear</button>
386
125
  </div>
126
+ <div class="hint">Mobile keyboard to type commands</div>
127
+ </div>
387
128
 
388
- <div id="input-container">
389
- <div id="input-row">
390
- <input type="text" id="input" placeholder="Type command..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
391
- <button id="send">Send</button>
392
- </div>
129
+ <div id="status-bar">
130
+ <div class="status-row">
131
+ <span class="status-dot" id="status-dot"></span>
132
+ <span id="status-text">Disconnected</span>
393
133
  </div>
134
+ <span id="session-id" style="color: #8b949e;"></span>
394
135
  </div>
395
136
 
396
- <script>
397
- (function() {
398
- 'use strict';
399
-
400
- // Parse URL params
401
- const params = new URLSearchParams(location.search);
402
- const token = params.get('t');
403
- const sessionId = params.get('s');
404
-
405
- // DOM elements
406
- const terminal = document.getElementById('terminal');
407
- const input = document.getElementById('input');
408
- const sendBtn = document.getElementById('send');
409
- const statusDot = document.getElementById('status-dot');
410
- const statusText = document.getElementById('status-text');
411
- const authScreen = document.getElementById('auth-screen');
412
- const authStatus = document.getElementById('auth-status');
413
- const notifications = document.getElementById('notifications');
414
-
415
- // State
416
- let ws = null;
417
- let reconnectAttempts = 0;
418
- const maxReconnectAttempts = 5;
419
- let terminalEnabled = true;
420
-
421
- // Connect to WebSocket
422
- function connect() {
423
- const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
424
- ws = new WebSocket(protocol + '//' + location.host);
425
-
426
- ws.onopen = function() {
427
- setStatus('connecting', 'Authenticating...');
428
- ws.send(JSON.stringify({ type: 'auth', token: token }));
429
- };
430
-
431
- ws.onmessage = function(event) {
432
- try {
433
- const msg = JSON.parse(event.data);
434
- handleMessage(msg);
435
- } catch (e) {
436
- console.error('Parse error:', e);
437
- }
438
- };
439
-
440
- ws.onclose = function() {
441
- setStatus('disconnected', 'Disconnected');
442
- if (reconnectAttempts < maxReconnectAttempts) {
443
- reconnectAttempts++;
444
- const delay = Math.min(2000 * reconnectAttempts, 10000);
445
- setTimeout(connect, delay);
446
- } else {
447
- authStatus.textContent = 'Connection failed. Refresh to retry.';
448
- authStatus.classList.add('error');
449
- authScreen.classList.remove('hidden');
450
- }
451
- };
452
-
453
- ws.onerror = function() {
454
- console.error('WebSocket error');
455
- };
456
- }
457
-
458
- // Handle incoming message
459
- function handleMessage(msg) {
460
- switch (msg.type) {
461
- case 'auth:required':
462
- // Already sent auth on open
463
- break;
464
-
465
- case 'auth:success':
466
- authScreen.classList.add('hidden');
467
- setStatus('connected', 'Connected');
468
- reconnectAttempts = 0;
469
- terminalEnabled = msg.payload?.terminalEnabled !== false;
470
- if (terminalEnabled) {
471
- appendOutput('\\x1b[32mConnected to NikCLI\\x1b[0m\\n\\n');
472
- }
473
- break;
474
-
475
- case 'auth:failed':
476
- authStatus.textContent = 'Authentication failed';
477
- authStatus.classList.add('error');
478
- break;
479
-
480
- case 'terminal:output':
481
- if (msg.payload?.data) {
482
- appendOutput(msg.payload.data);
483
- }
484
- break;
485
-
486
- case 'terminal:exit':
487
- appendOutput('\\n\\x1b[33m[Process exited with code ' + (msg.payload?.code || 0) + ']\\x1b[0m\\n');
488
- break;
489
-
490
- case 'notification':
491
- showNotification(msg.payload);
492
- break;
493
-
494
- case 'session:end':
495
- appendOutput('\\n\\x1b[31m[Session ended]\\x1b[0m\\n');
496
- setStatus('disconnected', 'Session ended');
497
- break;
498
-
499
- case 'pong':
500
- // Heartbeat response
501
- break;
502
-
503
- default:
504
- console.log('Unknown message:', msg.type);
505
- }
506
- }
507
-
508
- // Append text to terminal with ANSI support
509
- function appendOutput(text) {
510
- // Simple ANSI to HTML conversion
511
- const html = ansiToHtml(text);
512
- terminal.innerHTML += html;
513
- terminal.scrollTop = terminal.scrollHeight;
514
- }
515
-
516
- // Basic ANSI to HTML
517
- function ansiToHtml(text) {
518
- const ansiColors = {
519
- '30': '#6e7681', '31': '#f85149', '32': '#3fb950', '33': '#d29922',
520
- '34': '#58a6ff', '35': '#bc8cff', '36': '#76e3ea', '37': '#e6edf3',
521
- '90': '#6e7681', '91': '#f85149', '92': '#3fb950', '93': '#d29922',
522
- '94': '#58a6ff', '95': '#bc8cff', '96': '#76e3ea', '97': '#ffffff'
523
- };
524
-
525
- let result = '';
526
- let currentStyle = '';
527
-
528
- const parts = text.split(/\\x1b\\[([0-9;]+)m/);
529
- for (let i = 0; i < parts.length; i++) {
530
- if (i % 2 === 0) {
531
- // Text content
532
- result += escapeHtml(parts[i]);
533
- } else {
534
- // ANSI code
535
- const codes = parts[i].split(';');
536
- for (const code of codes) {
537
- if (code === '0') {
538
- if (currentStyle) {
539
- result += '</span>';
540
- currentStyle = '';
541
- }
542
- } else if (code === '1') {
543
- currentStyle = 'font-weight:bold;';
544
- result += '<span style="' + currentStyle + '">';
545
- } else if (ansiColors[code]) {
546
- if (currentStyle) result += '</span>';
547
- currentStyle = 'color:' + ansiColors[code] + ';';
548
- result += '<span style="' + currentStyle + '">';
549
- }
550
- }
551
- }
552
- }
137
+ <div id="terminal"></div>
553
138
 
554
- if (currentStyle) result += '</span>';
555
- return result;
556
- }
139
+ <div id="input-area">
140
+ <form class="input-row" onsubmit="return handleSubmit(event)">
141
+ <span class="prompt">$</span>
142
+ <input type="text" id="cmd-input" placeholder="Type command..." autocomplete="off" enterkeyhint="send" inputmode="text">
143
+ <button type="submit" id="send-btn">Send</button>
144
+ </form>
145
+ </div>
557
146
 
558
- // Escape HTML
559
- function escapeHtml(text) {
560
- return text
561
- .replace(/&/g, '&amp;')
562
- .replace(/</g, '&lt;')
563
- .replace(/>/g, '&gt;')
564
- .replace(/"/g, '&quot;')
565
- .replace(/\\n/g, '<br>')
566
- .replace(/ /g, '&nbsp;');
147
+ <!-- hterm from Google's Chromium -->
148
+ <script src="https://cdn.jsdelivr.net/npm/hterm@1.1.1/lib/hterm.js"></script>
149
+ <script>
150
+ let ws = null, term = null, connected = false, reconnectAttempts = 0;
151
+ const token = new URLSearchParams(location.search).get('t') || '';
152
+ const sessionId = new URLSearchParams(location.search).get('s') || '';
153
+
154
+ const authOverlay = document.getElementById('auth-overlay');
155
+ const authMsg = document.getElementById('auth-msg');
156
+ const authText = document.getElementById('auth-text');
157
+ const statusDot = document.getElementById('status-dot');
158
+ const statusText = document.getElementById('status-text');
159
+ const sessionSpan = document.getElementById('session-id');
160
+ const cmdInput = document.getElementById('cmd-input');
161
+
162
+ // Initialize hterm
163
+ hterm.DefaultCharWidth = 8.53;
164
+ hterm.DefaultRowHeight = 17;
165
+
166
+ const storage = new hterm.Storage.Memory();
167
+ const prefs = new hterm.Preferences(storage);
168
+ prefs.set('font-size', 14);
169
+ prefs.set('font-family', '"SF Mono", Monaco, Consolas, monospace');
170
+ prefs.set('background-color', '#0d1117');
171
+ prefs.set('foreground-color', '#e6edf3');
172
+ prefs.set('cursor-color', '#3fb950');
173
+ prefs.set('selection-color', '#264f78');
174
+ prefs.set('scrollbar-visible', false);
175
+ prefs.set('cursor-blink', true);
176
+
177
+ term = new hterm.Terminal(storage, prefs);
178
+ term.onTerminalReady = function() {
179
+ term.io.writeUTF8('\x1b[32mInitializing NikCLI Remote...\x1b[0m\r\n');
180
+ term.io.writeUTF8('\x1b[90mType commands below\x1b[0m\r\n');
181
+ connect();
182
+ };
183
+ term.decorate(document.getElementById('terminal'));
184
+ term.start();
185
+
186
+ // Resize handler
187
+ window.addEventListener('resize', () => {
188
+ const el = document.getElementById('terminal');
189
+ if (term && el) {
190
+ term.setHeightAndWidth(el.clientHeight / 17, el.clientWidth / 8.53);
567
191
  }
192
+ });
568
193
 
569
- // Set connection status
570
- function setStatus(state, text) {
571
- statusDot.className = state === 'connected' ? 'connected' :
572
- state === 'connecting' ? 'connecting' : '';
573
- statusText.textContent = text;
574
- }
194
+ function connect() {
195
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
196
+ ws = new WebSocket(protocol + '//' + location.host + '/ws');
575
197
 
576
- // Send data to terminal
577
- function send(data) {
578
- if (ws && ws.readyState === WebSocket.OPEN) {
579
- ws.send(JSON.stringify({ type: 'terminal:input', data: data }));
580
- }
581
- }
582
-
583
- // Show notification
584
- function showNotification(n) {
585
- if (!n) return;
586
- const el = document.createElement('div');
587
- el.className = 'notification ' + (n.type || 'info');
588
- el.innerHTML = '<h4>' + escapeHtml(n.title || 'Notification') + '</h4>' +
589
- '<p>' + escapeHtml(n.body || '') + '</p>';
590
- notifications.appendChild(el);
591
- setTimeout(function() { el.remove(); }, 5000);
592
- }
198
+ ws.onopen = () => {
199
+ setStatus('connecting', 'Authenticating...');
200
+ ws.send(JSON.stringify({ type: 'auth', token }));
201
+ reconnectAttempts = 0;
202
+ };
593
203
 
594
- // Event: Send button
595
- sendBtn.onclick = function() {
596
- if (input.value) {
597
- send(input.value + '\\r');
598
- input.value = '';
599
- }
600
- input.focus();
204
+ ws.onmessage = (e) => {
205
+ try { handleMessage(JSON.parse(e.data)); } catch (err) { console.error(err); }
601
206
  };
602
207
 
603
- // Event: Enter key in input
604
- input.onkeydown = function(e) {
605
- if (e.key === 'Enter') {
606
- e.preventDefault();
607
- sendBtn.click();
608
- }
208
+ ws.onclose = () => {
209
+ setStatus('disconnected', 'Disconnected');
210
+ connected = false;
211
+ reconnectAttempts++;
212
+ setTimeout(connect, Math.min(3000, reconnectAttempts * 500));
609
213
  };
610
214
 
611
- // Event: Quick keys
612
- document.querySelectorAll('.qkey').forEach(function(btn) {
613
- btn.onclick = function() {
614
- const key = btn.dataset.key;
615
- const decoded = key
616
- .replace(/\\\\x([0-9a-f]{2})/gi, function(_, hex) {
617
- return String.fromCharCode(parseInt(hex, 16));
618
- })
619
- .replace(/\\\\t/g, '\\t')
620
- .replace(/\\\\r/g, '\\r')
621
- .replace(/\\\\n/g, '\\n');
622
- send(decoded);
623
- input.focus();
624
- };
625
- });
215
+ ws.onerror = () => setStatus('disconnected', 'Connection error');
216
+ }
217
+
218
+ function handleMessage(msg) {
219
+ switch (msg.type) {
220
+ case 'auth:required':
221
+ ws.send(JSON.stringify({ type: 'auth', token }));
222
+ break;
223
+ case 'auth:success':
224
+ connected = true;
225
+ authOverlay.classList.add('hidden');
226
+ setStatus('connected', 'Connected');
227
+ sessionSpan.textContent = sessionId ? 'Session: ' + sessionId : '';
228
+ term.io.writeUTF8('\r\n\x1b[32m✓ Connected to NikCLI\x1b[0m\r\n');
229
+ break;
230
+ case 'auth:failed':
231
+ authMsg.classList.add('error');
232
+ authText.textContent = 'Authentication failed';
233
+ break;
234
+ case 'terminal:output':
235
+ if (msg.payload?.data) term.io.writeUTF8(msg.payload.data);
236
+ break;
237
+ case 'terminal:exit':
238
+ term.io.writeUTF8('\r\n\x1b[33m[Process exited: ' + (msg.payload?.code || 0) + ']\x1b[0m\r\n');
239
+ break;
240
+ }
241
+ }
626
242
 
627
- // Heartbeat
628
- setInterval(function() {
629
- if (ws && ws.readyState === WebSocket.OPEN) {
630
- ws.send(JSON.stringify({ type: 'ping' }));
631
- }
632
- }, 25000);
243
+ function setStatus(state, text) {
244
+ statusDot.className = 'status-dot ' + state;
245
+ statusText.textContent = text;
246
+ }
633
247
 
634
- // Handle resize
635
- function sendResize() {
636
- if (ws && ws.readyState === WebSocket.OPEN) {
637
- const cols = Math.floor(terminal.clientWidth / 8);
638
- const rows = Math.floor(terminal.clientHeight / 18);
639
- ws.send(JSON.stringify({ type: 'terminal:resize', cols: cols, rows: rows }));
640
- }
641
- }
248
+ function handleSubmit(e) {
249
+ e?.preventDefault();
250
+ const value = cmdInput.value.trim();
251
+ if (!value || !connected) return;
252
+ cmdInput.value = '';
253
+ term.io.writeUTF8('$ ' + value + '\r\n');
254
+ ws.send(JSON.stringify({ type: 'terminal:input', data: value + '\r' }));
255
+ setTimeout(() => cmdInput.focus(), 50);
256
+ return false;
257
+ }
642
258
 
643
- window.addEventListener('resize', sendResize);
644
- setTimeout(sendResize, 1000);
259
+ function send(cmd) {
260
+ if (!connected) return;
261
+ term.io.writeUTF8('$ ' + cmd + '\r\n');
262
+ ws.send(JSON.stringify({ type: 'terminal:input', data: cmd + '\r' }));
263
+ }
645
264
 
646
- // Start connection
647
- if (token) {
648
- connect();
649
- } else {
650
- authStatus.textContent = 'Invalid session URL';
651
- authStatus.classList.add('error');
265
+ cmdInput.addEventListener('keydown', (e) => {
266
+ if (e.key === 'Enter' && !e.shiftKey) {
267
+ e.preventDefault();
268
+ handleSubmit();
652
269
  }
653
- })();
270
+ });
271
+
272
+ document.getElementById('terminal')?.addEventListener('click', () => {
273
+ if (connected) cmdInput.focus();
274
+ });
654
275
  </script>
655
276
  </body>
656
- </html>`
277
+ </html>`;
657
278
  }