nikcli-remote 1.0.7 → 1.0.9

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 - Ghostty-web Style Terminal Client
3
- * Uses xterm.js with full terminal emulation
2
+ * @nikcli/remote - Mobile Web Client
3
+ * Touch-friendly terminal interface for mobile devices
4
4
  */
5
5
 
6
6
  export function getWebClient(): string {
@@ -8,293 +8,650 @@ 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, viewport-fit=cover">
11
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
12
12
  <meta name="apple-mobile-web-app-capable" content="yes">
13
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
13
+ <meta name="mobile-web-app-capable" content="yes">
14
+ <meta name="theme-color" content="#0d1117">
14
15
  <title>NikCLI Remote</title>
15
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
16
16
  <style>
17
- * { box-sizing: border-box; margin: 0; padding: 0; }
18
17
  :root {
19
- --bg-primary: #0d1117;
18
+ --bg: #0d1117;
20
19
  --bg-secondary: #161b22;
20
+ --fg: #e6edf3;
21
+ --fg-muted: #8b949e;
21
22
  --accent: #58a6ff;
22
23
  --success: #3fb950;
24
+ --warning: #d29922;
25
+ --error: #f85149;
23
26
  --border: #30363d;
27
+ --font-mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
24
28
  }
25
- html, body { height: 100%; overflow: hidden; touch-action: manipulation; }
26
- body {
27
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
28
- background: var(--bg-primary);
29
- color: #e6edf3;
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 {
30
48
  display: flex;
31
49
  flex-direction: column;
50
+ height: 100%;
51
+ height: 100dvh;
32
52
  }
33
- #terminal { flex: 1; overflow: hidden; background: var(--bg-primary); padding: 8px; }
34
- #input-area {
35
- background: var(--bg-secondary);
36
- border-top: 1px solid var(--border);
53
+
54
+ /* Header */
55
+ #header {
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: space-between;
37
59
  padding: 12px 16px;
60
+ background: var(--bg-secondary);
61
+ border-bottom: 1px solid var(--border);
38
62
  flex-shrink: 0;
39
- padding-bottom: env(safe-area-inset-bottom, 12px);
40
63
  }
41
- .input-row { display: flex; gap: 8px; align-items: center; }
42
- .prompt { color: var(--success); font-family: 'SF Mono', Monaco, monospace; font-size: 14px; white-space: nowrap; }
43
- #cmd-input {
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 {
44
114
  flex: 1;
45
- background: #21262d;
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);
46
169
  border: 1px solid var(--border);
47
- border-radius: 12px;
170
+ border-radius: 8px;
48
171
  padding: 12px 16px;
49
- color: #e6edf3;
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;
242
+ background: var(--bg-secondary);
243
+ border-top: 1px solid var(--border);
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ #input-row {
248
+ display: flex;
249
+ gap: 8px;
250
+ }
251
+
252
+ #input {
253
+ flex: 1;
254
+ background: var(--bg);
255
+ border: 1px solid var(--border);
256
+ border-radius: 8px;
257
+ padding: 12px 14px;
258
+ color: var(--fg);
259
+ font-family: var(--font-mono);
50
260
  font-size: 16px;
51
- font-family: 'SF Mono', Monaco, monospace;
52
261
  outline: none;
53
- -webkit-appearance: none;
262
+ transition: border-color 0.2s;
263
+ }
264
+
265
+ #input:focus {
266
+ border-color: var(--accent);
54
267
  }
55
- #cmd-input:focus { border-color: var(--accent); }
56
- #send-btn {
268
+
269
+ #input::placeholder {
270
+ color: var(--fg-muted);
271
+ }
272
+
273
+ #send {
57
274
  background: var(--accent);
58
- color: white;
275
+ color: #fff;
59
276
  border: none;
60
- border-radius: 12px;
61
- padding: 12px 24px;
62
- font-size: 16px;
277
+ border-radius: 8px;
278
+ padding: 12px 20px;
279
+ font-size: 14px;
63
280
  font-weight: 600;
281
+ font-family: var(--font-mono);
64
282
  cursor: pointer;
65
- -webkit-tap-highlight-color: transparent;
283
+ transition: opacity 0.2s, transform 0.1s;
66
284
  }
67
- #send-btn:active { opacity: 0.7; }
68
- #status-bar {
69
- background: var(--bg-secondary);
70
- border-bottom: 1px solid var(--border);
71
- padding: 8px 16px;
285
+
286
+ #send:active {
287
+ opacity: 0.8;
288
+ transform: scale(0.98);
289
+ }
290
+
291
+ /* Auth Screen */
292
+ #auth-screen {
293
+ position: fixed;
294
+ inset: 0;
295
+ background: var(--bg);
72
296
  display: flex;
73
- justify-content: space-between;
297
+ flex-direction: column;
74
298
  align-items: center;
75
- font-size: 12px;
76
- padding-top: env(safe-area-inset-top, 8px);
77
- }
78
- .status-row { display: flex; align-items: center; gap: 8px; }
79
- .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #8b949e; }
80
- .status-dot.connected { background: var(--success); box-shadow: 0 0 8px var(--success); }
81
- .status-dot.connecting { background: var(--accent); animation: pulse 1s infinite; }
82
- .status-dot.disconnected { background: #f85149; }
83
- @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
84
- #auth-overlay {
85
- position: fixed; inset: 0; background: var(--bg-primary);
86
- display: flex; flex-direction: column; align-items: center; justify-content: center;
87
- padding: 20px; z-index: 100;
88
- }
89
- #auth-overlay.hidden { display: none; }
90
- .auth-title { font-size: 28px; font-weight: 700; margin-bottom: 8px; }
91
- .auth-subtitle { color: #8b949e; font-size: 16px; margin-bottom: 24px; }
92
- .auth-msg {
93
- background: var(--bg-secondary); padding: 20px 28px;
94
- border-radius: 16px; border: 1px solid var(--border); text-align: center;
95
- }
96
- .auth-msg.error { color: #f85149; border-color: #f85149; }
97
- .quick-btns {
98
- display: flex; gap: 8px; margin-top: 20px; flex-wrap: wrap; justify-content: center;
99
- }
100
- .quick-btn {
101
- background: #21262d; border: 1px solid var(--border); color: #e6edf3;
102
- padding: 10px 16px; border-radius: 8px; font-size: 14px;
103
- font-family: 'SF Mono', Monaco, monospace; cursor: pointer;
104
- }
105
- .quick-btn:active { background: #30363d; }
106
- .hint { font-size: 12px; color: #8b949e; margin-top: 12px; }
107
- @media (max-width: 600px) {
108
- #input-area { padding: 10px 12px; }
109
- .quick-btn { padding: 8px 12px; font-size: 12px; }
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
+ }
110
350
  }
111
351
  </style>
112
352
  </head>
113
353
  <body>
114
- <div id="auth-overlay">
115
- <div class="auth-title">📱 NikCLI Remote</div>
116
- <div class="auth-subtitle">Full terminal emulation</div>
117
- <div id="auth-msg" class="auth-msg">
118
- <div id="auth-text">Connecting...</div>
119
- </div>
120
- <div class="quick-btns">
121
- <button class="quick-btn" onclick="send('help')">/help</button>
122
- <button class="quick-btn" onclick="send('ls -la')">ls -la</button>
123
- <button class="quick-btn" onclick="send('pwd')">pwd</button>
124
- <button class="quick-btn" onclick="send('whoami')">whoami</button>
125
- <button class="quick-btn" onclick="send('clear')">clear</button>
354
+ <div id="app">
355
+ <div id="auth-screen">
356
+ <div class="spinner"></div>
357
+ <p id="auth-status">Connecting to NikCLI...</p>
126
358
  </div>
127
- <div class="hint">Mobile keyboard to type commands</div>
128
- </div>
129
359
 
130
- <div id="status-bar">
131
- <div class="status-row">
132
- <span class="status-dot" id="status-dot"></span>
133
- <span id="status-text">Disconnected</span>
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>
134
370
  </div>
135
- <span id="session-id" style="color: #8b949e;"></span>
136
- </div>
137
371
 
138
- <div id="terminal"></div>
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>
386
+ </div>
139
387
 
140
- <div id="input-area">
141
- <form class="input-row" onsubmit="return handleSubmit(event)">
142
- <span class="prompt">$</span>
143
- <input type="text" id="cmd-input" placeholder="Type command..." autocomplete="off" enterkeyhint="send" inputmode="text">
144
- <button type="submit" id="send-btn">Send</button>
145
- </form>
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>
393
+ </div>
146
394
  </div>
147
395
 
148
- <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
149
- <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
150
396
  <script>
151
- let ws = null, term = null, fitAddon = null, connected = false, reconnectAttempts = 0;
152
- const token = new URLSearchParams(location.search).get('t') || '';
153
- const sessionId = new URLSearchParams(location.search).get('s') || '';
154
-
155
- const authOverlay = document.getElementById('auth-overlay');
156
- const authMsg = document.getElementById('auth-msg');
157
- const authText = document.getElementById('auth-text');
158
- const statusDot = document.getElementById('status-dot');
159
- const statusText = document.getElementById('status-text');
160
- const sessionSpan = document.getElementById('session-id');
161
- const cmdInput = document.getElementById('cmd-input');
162
-
163
- // Initialize xterm.js
164
- term = new Terminal({
165
- cursorBlink: true,
166
- fontSize: 14,
167
- fontFamily: '"SF Mono", Monaco, Consolas, monospace',
168
- theme: {
169
- background: '#0d1117',
170
- foreground: '#e6edf3',
171
- cursor: '#3fb950',
172
- selectionBackground: '#264f78',
173
- black: '#484f58',
174
- red: '#f85149',
175
- green: '#3fb950',
176
- yellow: '#d29922',
177
- blue: '#58a6ff',
178
- magenta: '#a371f7',
179
- cyan: '#39c5cf',
180
- white: '#e6edf3',
181
- brightBlack: '#6e7681',
182
- brightRed: '#ffa198',
183
- brightGreen: '#7ee787',
184
- brightYellow: '#f0883e',
185
- brightBlue: '#79c0ff',
186
- brightMagenta: '#d2a8ff',
187
- brightCyan: '#56d4db',
188
- brightWhite: '#f0f6fc'
189
- },
190
- convertEol: true
191
- });
192
-
193
- fitAddon = new FitAddon.FitAddon();
194
- term.loadAddon(fitAddon);
195
- term.open(document.getElementById('terminal'));
196
- fitAddon.fit();
197
- term.writeln('Initializing NikCLI Remote...);
198
- term.writeln('Type commands below');
199
-
200
- // Resize handler
201
- window.addEventListener('resize', () => {
202
- clearTimeout(window.resizeTimer);
203
- window.resizeTimer = setTimeout(() => fitAddon.fit(), 100);
204
- });
205
-
206
- // Visual viewport for mobile keyboard
207
- if (window.visualViewport) {
208
- window.visualViewport.addEventListener('resize', () => {
209
- clearTimeout(window.resizeTimer);
210
- window.resizeTimer = setTimeout(() => fitAddon.fit(), 100);
211
- });
212
- }
397
+ (function() {
398
+ 'use strict';
213
399
 
214
- function connect() {
215
- const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
216
- ws = new WebSocket(protocol + '//' + location.host + '/ws');
400
+ // Parse URL params
401
+ const params = new URLSearchParams(location.search);
402
+ const token = params.get('t');
403
+ const sessionId = params.get('s');
217
404
 
218
- ws.onopen = () => {
219
- setStatus('connecting', 'Authenticating...');
220
- ws.send(JSON.stringify({ type: 'auth', token }));
221
- reconnectAttempts = 0;
222
- };
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');
223
414
 
224
- ws.onmessage = (e) => {
225
- try { handleMessage(JSON.parse(e.data)); } catch (err) { console.error(err); }
226
- };
415
+ // State
416
+ let ws = null;
417
+ let reconnectAttempts = 0;
418
+ const maxReconnectAttempts = 5;
419
+ let terminalEnabled = true;
227
420
 
228
- ws.onclose = () => {
229
- setStatus('disconnected', 'Disconnected');
230
- connected = false;
231
- reconnectAttempts++;
232
- setTimeout(connect, Math.min(3000, reconnectAttempts * 500));
233
- };
421
+ // Connect to WebSocket
422
+ function connect() {
423
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
424
+ ws = new WebSocket(protocol + '//' + location.host);
234
425
 
235
- ws.onerror = () => setStatus('disconnected', 'Connection error');
236
- }
237
-
238
- function handleMessage(msg) {
239
- switch (msg.type) {
240
- case 'auth:required':
241
- ws.send(JSON.stringify({ type: 'auth', token }));
242
- break;
243
- case 'auth:success':
244
- connected = true;
245
- authOverlay.classList.add('hidden');
246
- setStatus('connected', 'Connected');
247
- sessionSpan.textContent = sessionId ? 'Session: ' + sessionId : '';
248
- term.writeln('✓ Connected to NikCLI');
249
- break;
250
- case 'auth:failed':
251
- authMsg.classList.add('error');
252
- authText.textContent = 'Authentication failed';
253
- break;
254
- case 'terminal:output':
255
- if (msg.payload?.data) term.write(msg.payload.data);
256
- break;
257
- case 'terminal:exit':
258
- term.writeln('Process exited: ' + (msg.payload?.code || 0) + '');
259
- break;
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
+ };
260
456
  }
261
- }
262
457
 
263
- function setStatus(state, text) {
264
- statusDot.className = 'status-dot ' + state;
265
- statusText.textContent = text;
266
- }
458
+ // Handle incoming message
459
+ function handleMessage(msg) {
460
+ switch (msg.type) {
461
+ case 'auth:required':
462
+ // Already sent auth on open
463
+ break;
267
464
 
268
- function handleSubmit(e) {
269
- if (e) e.preventDefault();
270
- const value = cmdInput.value.trim();
271
- if (!value || !connected) return;
272
- cmdInput.value = '';
273
- term.write('$ ' + value );
274
- ws.send(JSON.stringify({ type: 'terminal:input', data: value + '\r' }));
275
- setTimeout(() => cmdInput.focus(), 50);
276
- return false;
277
- }
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;
278
474
 
279
- function send(cmd) {
280
- if (!connected) return;
281
- term.write('$ ' + cmd + );
282
- ws.send(JSON.stringify({ type: 'terminal:input', data: cmd + '\r' }));
283
- }
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;
284
498
 
285
- cmdInput.addEventListener('keydown', (e) => {
286
- if (e.key === 'Enter' && !e.shiftKey) {
287
- e.preventDefault();
288
- handleSubmit();
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;
289
514
  }
290
- });
291
515
 
292
- document.getElementById('terminal')?.addEventListener('click', () => {
293
- if (connected) cmdInput.focus();
294
- });
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
+ }
295
553
 
296
- connect();
554
+ if (currentStyle) result += '</span>';
555
+ return result;
556
+ }
557
+
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;');
567
+ }
568
+
569
+ // Set connection status
570
+ function setStatus(state, text) {
571
+ statusDot.className = state === 'connected' ? 'connected' :
572
+ state === 'connecting' ? 'connecting' : '';
573
+ statusText.textContent = text;
574
+ }
575
+
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
+ }
593
+
594
+ // Event: Send button
595
+ sendBtn.onclick = function() {
596
+ if (input.value) {
597
+ send(input.value + '\\r');
598
+ input.value = '';
599
+ }
600
+ input.focus();
601
+ };
602
+
603
+ // Event: Enter key in input
604
+ input.onkeydown = function(e) {
605
+ if (e.key === 'Enter') {
606
+ e.preventDefault();
607
+ sendBtn.click();
608
+ }
609
+ };
610
+
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
+ });
626
+
627
+ // Heartbeat
628
+ setInterval(function() {
629
+ if (ws && ws.readyState === WebSocket.OPEN) {
630
+ ws.send(JSON.stringify({ type: 'ping' }));
631
+ }
632
+ }, 25000);
633
+
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
+ }
642
+
643
+ window.addEventListener('resize', sendResize);
644
+ setTimeout(sendResize, 1000);
645
+
646
+ // Start connection
647
+ if (token) {
648
+ connect();
649
+ } else {
650
+ authStatus.textContent = 'Invalid session URL';
651
+ authStatus.classList.add('error');
652
+ }
653
+ })();
297
654
  </script>
298
655
  </body>
299
- </html>`;
656
+ </html>`
300
657
  }