ccwebtty 1.0.0

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,1038 @@
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, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
6
+ <title>ccweb - Web Terminal</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css" />
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10
+ html, body { height: 100%; overflow: hidden; background: #1a1b26; }
11
+ html { height: 100%; height: -webkit-fill-available; }
12
+
13
+ #tab-bar {
14
+ position: fixed;
15
+ top: 0; left: 0; right: 0;
16
+ height: 36px;
17
+ background: #16161e;
18
+ display: flex;
19
+ align-items: stretch;
20
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
21
+ font-size: 12px;
22
+ color: #565f89;
23
+ z-index: 10;
24
+ border-bottom: 1px solid #292e42;
25
+ user-select: none;
26
+ }
27
+
28
+ #tab-scroll {
29
+ flex: 1;
30
+ min-width: 0;
31
+ display: flex;
32
+ align-items: stretch;
33
+ overflow-x: auto;
34
+ overflow-y: hidden;
35
+ }
36
+
37
+ #tab-scroll::-webkit-scrollbar { height: 0; }
38
+
39
+ #tab-actions {
40
+ display: flex;
41
+ align-items: stretch;
42
+ flex-shrink: 0;
43
+ border-left: 1px solid #292e42;
44
+ }
45
+
46
+ .tab {
47
+ display: flex;
48
+ align-items: center;
49
+ padding: 0 12px;
50
+ height: 100%;
51
+ cursor: pointer;
52
+ white-space: nowrap;
53
+ border-right: 1px solid #292e42;
54
+ transition: background 0.15s, color 0.15s;
55
+ min-width: 0;
56
+ flex-shrink: 0;
57
+ }
58
+
59
+ .tab:hover { background: #1e2030; }
60
+ .tab.active { background: #1a1b26; color: #c0caf5; }
61
+
62
+ .tab .dot {
63
+ width: 7px; height: 7px;
64
+ border-radius: 50%;
65
+ margin-right: 7px;
66
+ flex-shrink: 0;
67
+ transition: background 0.3s;
68
+ }
69
+
70
+ .tab .dot.connected { background: #9ece6a; }
71
+ .tab .dot.disconnected { background: #f7768e; }
72
+ .tab .dot.connecting { background: #e0af68; }
73
+
74
+ .tab .label { margin-right: 8px; }
75
+
76
+ .tab .close-btn {
77
+ width: 18px; height: 18px;
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ border-radius: 4px;
82
+ font-size: 14px;
83
+ line-height: 1;
84
+ color: #565f89;
85
+ flex-shrink: 0;
86
+ }
87
+
88
+ .tab .close-btn:hover { background: #33467c; color: #c0caf5; }
89
+
90
+ .bar-btn {
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: center;
94
+ width: 36px;
95
+ flex-shrink: 0;
96
+ cursor: pointer;
97
+ color: #565f89;
98
+ transition: background 0.15s, color 0.15s;
99
+ }
100
+
101
+ .bar-btn svg {
102
+ width: 16px;
103
+ height: 16px;
104
+ }
105
+
106
+ .bar-btn:hover { background: #1e2030; color: #c0caf5; }
107
+ .bar-btn.active { color: #7aa2f7; }
108
+
109
+ #terminals-area {
110
+ position: absolute;
111
+ top: 36px; left: 0; right: 0; bottom: 0;
112
+ transition: bottom 0.15s;
113
+ }
114
+ body.vk-visible #vk-toggle { position: absolute; right: 4px; top: 4px; z-index: 20; }
115
+
116
+ /* ---- Tab mode (default) ---- */
117
+ .terminal-pane {
118
+ width: 100%;
119
+ height: 100%;
120
+ display: none;
121
+ position: relative;
122
+ }
123
+
124
+ .terminal-pane .pane-content {
125
+ width: 100%;
126
+ height: 100%;
127
+ padding: 4px;
128
+ }
129
+
130
+ .terminal-pane.active { display: block; }
131
+ .terminal-pane .xterm { height: 100%; }
132
+
133
+ .pane-header {
134
+ display: none;
135
+ }
136
+
137
+ /* ---- Grid mode ---- */
138
+ body.grid-mode #terminals-area {
139
+ display: grid;
140
+ gap: 2px;
141
+ padding: 2px;
142
+ }
143
+
144
+ body.grid-mode .terminal-pane {
145
+ display: flex;
146
+ flex-direction: column;
147
+ overflow: hidden;
148
+ border: 2px solid transparent;
149
+ border-radius: 6px;
150
+ transition: border-color 0.15s;
151
+ }
152
+
153
+ body.grid-mode .terminal-pane.active {
154
+ border-color: #7aa2f7;
155
+ }
156
+
157
+ body.grid-mode .pane-header {
158
+ display: flex;
159
+ align-items: center;
160
+ height: 26px;
161
+ padding: 0 8px;
162
+ background: #16161e;
163
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
164
+ font-size: 11px;
165
+ color: #565f89;
166
+ user-select: none;
167
+ flex-shrink: 0;
168
+ border-bottom: 1px solid #292e42;
169
+ gap: 6px;
170
+ }
171
+
172
+ body.grid-mode .terminal-pane.active .pane-header {
173
+ color: #c0caf5;
174
+ background: #1e2030;
175
+ }
176
+
177
+ .pane-header .pane-dot {
178
+ width: 6px; height: 6px;
179
+ border-radius: 50%;
180
+ flex-shrink: 0;
181
+ }
182
+ .pane-dot.connected { background: #9ece6a; }
183
+ .pane-dot.disconnected { background: #f7768e; }
184
+ .pane-dot.connecting { background: #e0af68; }
185
+
186
+ .pane-header .pane-label { flex: 1; }
187
+
188
+ .pane-header .pane-close {
189
+ width: 16px; height: 16px;
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ border-radius: 3px;
194
+ font-size: 13px;
195
+ line-height: 1;
196
+ color: #565f89;
197
+ cursor: pointer;
198
+ }
199
+ .pane-header .pane-close:hover { background: #33467c; color: #c0caf5; }
200
+
201
+ body.grid-mode .pane-content {
202
+ flex: 1;
203
+ min-height: 0;
204
+ padding: 2px;
205
+ }
206
+
207
+ /* Hide tab list in grid mode, keep action buttons */
208
+ body.grid-mode #tab-scroll { display: none; }
209
+
210
+ /* Virtual keyboard toolbar */
211
+ #vk-toggle {
212
+ position: fixed;
213
+ bottom: 8px;
214
+ right: 8px;
215
+ z-index: 20;
216
+ width: 32px;
217
+ height: 32px;
218
+ border-radius: 6px;
219
+ background: #292e42;
220
+ color: #565f89;
221
+ border: 1px solid #3b4261;
222
+ display: flex;
223
+ align-items: center;
224
+ justify-content: center;
225
+ cursor: pointer;
226
+ font-size: 16px;
227
+ line-height: 1;
228
+ transition: background 0.15s, color 0.15s;
229
+ -webkit-tap-highlight-color: transparent;
230
+ }
231
+ #vk-toggle:hover, #vk-toggle.active { background: #33467c; color: #c0caf5; }
232
+
233
+ #vk-bar {
234
+ position: fixed;
235
+ bottom: 0; left: 0; right: 0;
236
+ background: #16161e;
237
+ border-top: 1px solid #292e42;
238
+ z-index: 15;
239
+ display: none;
240
+ flex-direction: column;
241
+ gap: 4px;
242
+ padding: 6px 4px;
243
+ padding-bottom: max(6px, env(safe-area-inset-bottom));
244
+ -webkit-tap-highlight-color: transparent;
245
+ }
246
+ body.vk-visible #vk-bar { display: flex; }
247
+
248
+ .vk-row {
249
+ display: flex;
250
+ gap: 4px;
251
+ justify-content: center;
252
+ flex-wrap: nowrap;
253
+ overflow-x: auto;
254
+ }
255
+ .vk-row::-webkit-scrollbar { height: 0; }
256
+
257
+ .vk-key {
258
+ flex: 1 1 0;
259
+ min-width: 44px;
260
+ height: 42px;
261
+ padding: 0 6px;
262
+ border-radius: 6px;
263
+ background: #292e42;
264
+ color: #a9b1d6;
265
+ border: 1px solid #3b4261;
266
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
267
+ font-size: 14px;
268
+ display: flex;
269
+ align-items: center;
270
+ justify-content: center;
271
+ cursor: pointer;
272
+ user-select: none;
273
+ white-space: nowrap;
274
+ transition: background 0.1s, color 0.1s;
275
+ -webkit-tap-highlight-color: transparent;
276
+ }
277
+ .vk-key:active { background: #33467c; }
278
+ .vk-key.modifier { color: #7aa2f7; }
279
+ .vk-key.modifier.held { background: #7aa2f7; color: #1a1b26; border-color: #7aa2f7; }
280
+ .vk-key.modifier.locked { background: #9ece6a; color: #1a1b26; border-color: #9ece6a; }
281
+
282
+ .vk-key.wide { min-width: 56px; }
283
+ .vk-key.arrow { font-size: 18px; }
284
+
285
+ </style>
286
+ </head>
287
+ <body>
288
+ <div id="tab-bar">
289
+ <div id="tab-scroll"></div>
290
+ <div id="tab-actions">
291
+ <div id="new-tab-btn" class="bar-btn" title="New Terminal (Ctrl+Shift+T)">
292
+ <svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 3a.75.75 0 0 1 .75.75v3.5h3.5a.75.75 0 0 1 0 1.5h-3.5v3.5a.75.75 0 0 1-1.5 0v-3.5h-3.5a.75.75 0 0 1 0-1.5h3.5v-3.5A.75.75 0 0 1 8 3z"/></svg>
293
+ </div>
294
+ <div id="grid-toggle-btn" class="bar-btn" title="Toggle grid view (Ctrl+Shift+G)">
295
+ <svg id="grid-icon-enter" viewBox="0 0 16 16"><rect x="1" y="1" width="6" height="6" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="1" width="6" height="6" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="1" y="9" width="6" height="6" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.4"/><rect x="9" y="9" width="6" height="6" rx="1.2" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>
296
+ <svg id="grid-icon-exit" viewBox="0 0 16 16" style="display:none"><rect x="2" y="2" width="12" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
297
+ </div>
298
+ </div>
299
+ </div>
300
+ <div id="terminals-area"></div>
301
+
302
+ <div id="vk-toggle" title="Toggle virtual keys">&#9000;</div>
303
+ <div id="vk-bar">
304
+ <div class="vk-row" id="vk-row-modifiers"></div>
305
+ <div class="vk-row" id="vk-row-keys"></div>
306
+ </div>
307
+
308
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
309
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
310
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
311
+ <script>
312
+ (function () {
313
+ var tabScroll = document.getElementById('tab-scroll');
314
+ var newTabBtn = document.getElementById('new-tab-btn');
315
+ var gridToggleBtn = document.getElementById('grid-toggle-btn');
316
+ var gridIconEnter = document.getElementById('grid-icon-enter');
317
+ var gridIconExit = document.getElementById('grid-icon-exit');
318
+ var terminalsArea = document.getElementById('terminals-area');
319
+
320
+ // Map of serverSessionId -> local tab state
321
+ var tabs = {};
322
+ var tabOrder = [];
323
+ var activeSessionId = null;
324
+ var controlWs = null;
325
+ var controlReconnectTimer = null;
326
+ var gridMode = false;
327
+
328
+ var TERM_OPTIONS = {
329
+ cursorBlink: true,
330
+ fontSize: 14,
331
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
332
+ theme: {
333
+ background: '#1a1b26',
334
+ foreground: '#a9b1d6',
335
+ cursor: '#c0caf5',
336
+ selectionBackground: '#33467c',
337
+ black: '#15161e',
338
+ red: '#f7768e',
339
+ green: '#9ece6a',
340
+ yellow: '#e0af68',
341
+ blue: '#7aa2f7',
342
+ magenta: '#bb9af7',
343
+ cyan: '#7dcfff',
344
+ white: '#a9b1d6',
345
+ brightBlack: '#414868',
346
+ brightRed: '#f7768e',
347
+ brightGreen: '#9ece6a',
348
+ brightYellow: '#e0af68',
349
+ brightBlue: '#7aa2f7',
350
+ brightMagenta: '#bb9af7',
351
+ brightCyan: '#7dcfff',
352
+ brightWhite: '#c0caf5',
353
+ },
354
+ allowProposedApi: true,
355
+ };
356
+
357
+ function getWsUrl(params) {
358
+ var protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
359
+ return protocol + '//' + location.host + '?' + params.join('&');
360
+ }
361
+
362
+ // ---- Grid layout ----
363
+ function getGridCols(count) {
364
+ if (count <= 1) return 1;
365
+ if (count <= 2) return 2;
366
+ if (count <= 4) return 2;
367
+ if (count <= 6) return 3;
368
+ if (count <= 9) return 3;
369
+ return 4;
370
+ }
371
+
372
+ function updateGridLayout() {
373
+ if (!gridMode) {
374
+ terminalsArea.style.gridTemplateColumns = '';
375
+ terminalsArea.style.gridTemplateRows = '';
376
+ return;
377
+ }
378
+ var count = tabOrder.length;
379
+ var cols = getGridCols(count);
380
+ var rows = Math.ceil(count / cols);
381
+ terminalsArea.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
382
+ terminalsArea.style.gridTemplateRows = 'repeat(' + rows + ', 1fr)';
383
+ }
384
+
385
+ function setGridMode(enabled) {
386
+ gridMode = enabled;
387
+ document.body.classList.toggle('grid-mode', enabled);
388
+ gridToggleBtn.classList.toggle('active', enabled);
389
+ gridIconEnter.style.display = enabled ? 'none' : 'block';
390
+ gridIconExit.style.display = enabled ? 'block' : 'none';
391
+ localStorage.setItem('ccweb_grid', enabled ? '1' : '0');
392
+ updateGridLayout();
393
+ refitAllTabs();
394
+ }
395
+
396
+ function refitAllTabs() {
397
+ // Small delay to let CSS settle
398
+ setTimeout(function () {
399
+ tabOrder.forEach(function (id) {
400
+ var tab = tabs[id];
401
+ if (!tab) return;
402
+ tab.fitAddon.fit();
403
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
404
+ tab.ws.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows }));
405
+ }
406
+ });
407
+ }, 50);
408
+ }
409
+
410
+ gridToggleBtn.addEventListener('click', function () {
411
+ setGridMode(!gridMode);
412
+ });
413
+
414
+ // ---- Control WebSocket ----
415
+ function connectControl() {
416
+ if (controlWs && (controlWs.readyState === WebSocket.CONNECTING || controlWs.readyState === WebSocket.OPEN)) {
417
+ return;
418
+ }
419
+
420
+ controlWs = new WebSocket(getWsUrl([]));
421
+
422
+ controlWs.onmessage = function (event) {
423
+ try {
424
+ var msg = JSON.parse(event.data);
425
+ if (msg.type === 'tabs') {
426
+ syncTabs(msg.tabs);
427
+ }
428
+ } catch (e) {}
429
+ };
430
+
431
+ controlWs.onclose = function () {
432
+ scheduleControlReconnect();
433
+ };
434
+
435
+ controlWs.onerror = function () {
436
+ controlWs.close();
437
+ };
438
+ }
439
+
440
+ function scheduleControlReconnect() {
441
+ if (controlReconnectTimer) return;
442
+ controlReconnectTimer = setTimeout(function () {
443
+ controlReconnectTimer = null;
444
+ connectControl();
445
+ }, 2000);
446
+ }
447
+
448
+ function controlSend(msg) {
449
+ if (controlWs && controlWs.readyState === WebSocket.OPEN) {
450
+ controlWs.send(JSON.stringify(msg));
451
+ }
452
+ }
453
+
454
+ // ---- Tab sync from server ----
455
+ function syncTabs(serverTabs) {
456
+ var serverSet = {};
457
+ serverTabs.forEach(function (id) { serverSet[id] = true; });
458
+
459
+ tabOrder.forEach(function (id) {
460
+ if (!serverSet[id] && tabs[id]) {
461
+ removeLocalTab(id);
462
+ }
463
+ });
464
+
465
+ tabOrder = [];
466
+ serverTabs.forEach(function (id) {
467
+ tabOrder.push(id);
468
+ if (!tabs[id]) {
469
+ createLocalTab(id);
470
+ }
471
+ });
472
+
473
+ if (!tabs[activeSessionId] && tabOrder.length > 0) {
474
+ activateTab(tabOrder[0]);
475
+ }
476
+
477
+ rebuildTabBar();
478
+ updatePaneHeaders();
479
+ updateGridLayout();
480
+
481
+ if (gridMode) {
482
+ refitAllTabs();
483
+ }
484
+ }
485
+
486
+ function createLocalTab(sessionId) {
487
+ var pane = document.createElement('div');
488
+ pane.className = 'terminal-pane';
489
+ pane.id = 'pane-' + sessionId;
490
+
491
+ // Pane header (visible in grid mode)
492
+ var header = document.createElement('div');
493
+ header.className = 'pane-header';
494
+
495
+ var paneDot = document.createElement('span');
496
+ paneDot.className = 'pane-dot connecting';
497
+
498
+ var paneLabel = document.createElement('span');
499
+ paneLabel.className = 'pane-label';
500
+ paneLabel.textContent = 'Terminal';
501
+
502
+ var paneClose = document.createElement('span');
503
+ paneClose.className = 'pane-close';
504
+ paneClose.textContent = '\u00d7';
505
+
506
+ header.appendChild(paneDot);
507
+ header.appendChild(paneLabel);
508
+ header.appendChild(paneClose);
509
+
510
+ var content = document.createElement('div');
511
+ content.className = 'pane-content';
512
+
513
+ pane.appendChild(header);
514
+ pane.appendChild(content);
515
+ terminalsArea.appendChild(pane);
516
+
517
+ var term = new Terminal(TERM_OPTIONS);
518
+ var fitAddon = new FitAddon.FitAddon();
519
+ var webLinksAddon = new WebLinksAddon.WebLinksAddon();
520
+ term.loadAddon(fitAddon);
521
+ term.loadAddon(webLinksAddon);
522
+ term.open(content);
523
+
524
+ // Disable iOS autofill/autocorrect bar
525
+ var xtermTA = content.querySelector('.xterm-helper-textarea');
526
+ if (xtermTA) {
527
+ xtermTA.setAttribute('autocomplete', 'off');
528
+ xtermTA.setAttribute('autocorrect', 'off');
529
+ xtermTA.setAttribute('autocapitalize', 'none');
530
+ xtermTA.setAttribute('spellcheck', 'false');
531
+ xtermTA.setAttribute('data-form-type', 'other');
532
+ xtermTA.setAttribute('data-lpignore', 'true');
533
+ xtermTA.setAttribute('data-1p-ignore', 'true');
534
+ }
535
+
536
+ var tab = {
537
+ sessionId: sessionId,
538
+ term: term,
539
+ fitAddon: fitAddon,
540
+ pane: pane,
541
+ paneDot: paneDot,
542
+ paneLabel: paneLabel,
543
+ tabEl: null,
544
+ dotEl: null,
545
+ ws: null,
546
+ reconnectTimer: null,
547
+ reconnectAttempts: 0,
548
+ status: 'connecting',
549
+ disposed: false,
550
+ };
551
+
552
+ term.onData(function (data) {
553
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
554
+ tab.ws.send(JSON.stringify({ type: 'input', data: data }));
555
+ }
556
+ });
557
+
558
+ // Touch scrolling support for mobile browsers
559
+ (function (termEl, terminal) {
560
+ var touchStartY = 0;
561
+ var touchAccum = 0;
562
+ var lineHeight = Math.ceil(terminal.options.fontSize * 1.2) || 17;
563
+
564
+ termEl.addEventListener('touchstart', function (e) {
565
+ if (e.touches.length === 1) {
566
+ touchStartY = e.touches[0].clientY;
567
+ touchAccum = 0;
568
+ }
569
+ }, { passive: true });
570
+
571
+ termEl.addEventListener('touchmove', function (e) {
572
+ if (e.touches.length !== 1) return;
573
+ var dy = touchStartY - e.touches[0].clientY;
574
+ touchStartY = e.touches[0].clientY;
575
+ touchAccum += dy;
576
+ var lines = Math.trunc(touchAccum / lineHeight);
577
+ if (lines !== 0) {
578
+ touchAccum -= lines * lineHeight;
579
+ terminal.scrollLines(lines);
580
+ }
581
+ }, { passive: true });
582
+ })(content, term);
583
+
584
+ // Click pane to activate (grid mode)
585
+ header.addEventListener('click', function (e) {
586
+ if (e.target === paneClose || paneClose.contains(e.target)) {
587
+ controlSend({ type: 'kill', sessionId: sessionId });
588
+ } else {
589
+ activateTab(sessionId);
590
+ }
591
+ });
592
+
593
+ // Double-click header to maximize (switch to tab mode)
594
+ header.addEventListener('dblclick', function (e) {
595
+ if (e.target === paneClose || paneClose.contains(e.target)) return;
596
+ activateTab(sessionId);
597
+ setGridMode(false);
598
+ });
599
+
600
+ // Click on terminal area also activates in grid mode
601
+ content.addEventListener('mousedown', function () {
602
+ if (gridMode && activeSessionId !== sessionId) {
603
+ activateTab(sessionId);
604
+ }
605
+ });
606
+
607
+ tabs[sessionId] = tab;
608
+ connectTab(tab);
609
+
610
+ if (!activeSessionId) {
611
+ activateTab(sessionId);
612
+ }
613
+
614
+ return tab;
615
+ }
616
+
617
+ function removeLocalTab(sessionId) {
618
+ var tab = tabs[sessionId];
619
+ if (!tab) return;
620
+ tab.disposed = true;
621
+ if (tab.reconnectTimer) clearTimeout(tab.reconnectTimer);
622
+ if (tab.ws) {
623
+ tab.ws.onclose = null;
624
+ tab.ws.close();
625
+ }
626
+ tab.term.dispose();
627
+ tab.pane.remove();
628
+ delete tabs[sessionId];
629
+ }
630
+
631
+ function connectTab(tab) {
632
+ if (tab.disposed) return;
633
+ if (tab.ws && (tab.ws.readyState === WebSocket.CONNECTING || tab.ws.readyState === WebSocket.OPEN)) {
634
+ return;
635
+ }
636
+
637
+ setTabStatus(tab, 'connecting');
638
+
639
+ var params = [
640
+ 'sessionId=' + tab.sessionId,
641
+ 'cols=' + tab.term.cols,
642
+ 'rows=' + tab.term.rows,
643
+ ];
644
+ var ws = new WebSocket(getWsUrl(params));
645
+ tab.ws = ws;
646
+
647
+ ws.onopen = function () {
648
+ setTabStatus(tab, 'connected');
649
+ tab.reconnectAttempts = 0;
650
+ ws.send(JSON.stringify({
651
+ type: 'resize',
652
+ cols: tab.term.cols,
653
+ rows: tab.term.rows,
654
+ }));
655
+ };
656
+
657
+ ws.onmessage = function (event) {
658
+ try {
659
+ var msg = JSON.parse(event.data);
660
+ if (msg.type === 'output') {
661
+ tab.term.write(msg.data);
662
+ } else if (msg.type === 'exit') {
663
+ tab.disposed = true;
664
+ setTabStatus(tab, 'disconnected');
665
+ }
666
+ } catch (e) {}
667
+ };
668
+
669
+ ws.onclose = function () {
670
+ if (tab.disposed) return;
671
+ setTabStatus(tab, 'disconnected');
672
+ scheduleTabReconnect(tab);
673
+ };
674
+
675
+ ws.onerror = function () {
676
+ ws.close();
677
+ };
678
+ }
679
+
680
+ function scheduleTabReconnect(tab) {
681
+ if (tab.disposed || tab.reconnectTimer) return;
682
+ tab.reconnectAttempts++;
683
+ var delay = Math.min(1000 * Math.pow(1.5, tab.reconnectAttempts - 1), 5000);
684
+ setTabStatus(tab, 'connecting');
685
+ tab.reconnectTimer = setTimeout(function () {
686
+ tab.reconnectTimer = null;
687
+ connectTab(tab);
688
+ }, delay);
689
+ }
690
+
691
+ function setTabStatus(tab, state) {
692
+ tab.status = state;
693
+ if (tab.dotEl) {
694
+ tab.dotEl.className = 'dot ' + state;
695
+ }
696
+ if (tab.paneDot) {
697
+ tab.paneDot.className = 'pane-dot ' + state;
698
+ }
699
+ }
700
+
701
+ // ---- Update pane headers (numbering) ----
702
+ function updatePaneHeaders() {
703
+ tabOrder.forEach(function (id, index) {
704
+ var tab = tabs[id];
705
+ if (tab && tab.paneLabel) {
706
+ tab.paneLabel.textContent = 'Terminal ' + (index + 1);
707
+ }
708
+ });
709
+ }
710
+
711
+ // ---- Tab bar rendering ----
712
+ function rebuildTabBar() {
713
+ tabScroll.innerHTML = '';
714
+ tabOrder.forEach(function (id, index) {
715
+ var tab = tabs[id];
716
+ if (!tab) return;
717
+ var el = createTabEl(tab, index + 1);
718
+ tabScroll.appendChild(el);
719
+ });
720
+ }
721
+
722
+ function createTabEl(tab, num) {
723
+ var el = document.createElement('div');
724
+ el.className = 'tab' + (tab.sessionId === activeSessionId ? ' active' : '');
725
+ el.dataset.sessionId = tab.sessionId;
726
+
727
+ var dot = document.createElement('span');
728
+ dot.className = 'dot ' + tab.status;
729
+
730
+ var label = document.createElement('span');
731
+ label.className = 'label';
732
+ label.textContent = 'Terminal ' + num;
733
+
734
+ var closeBtn = document.createElement('span');
735
+ closeBtn.className = 'close-btn';
736
+ closeBtn.textContent = '\u00d7';
737
+
738
+ el.appendChild(dot);
739
+ el.appendChild(label);
740
+ el.appendChild(closeBtn);
741
+
742
+ el.addEventListener('click', function (e) {
743
+ if (e.target === closeBtn || closeBtn.contains(e.target)) {
744
+ controlSend({ type: 'kill', sessionId: tab.sessionId });
745
+ } else {
746
+ activateTab(tab.sessionId);
747
+ }
748
+ });
749
+
750
+ tab.tabEl = el;
751
+ tab.dotEl = dot;
752
+ return el;
753
+ }
754
+
755
+ function activateTab(sessionId) {
756
+ activeSessionId = sessionId;
757
+ tabOrder.forEach(function (id) {
758
+ var tab = tabs[id];
759
+ if (!tab) return;
760
+ var isActive = id === sessionId;
761
+ tab.pane.classList.toggle('active', isActive);
762
+ if (tab.tabEl) tab.tabEl.classList.toggle('active', isActive);
763
+ if (isActive) {
764
+ tab.fitAddon.fit();
765
+ tab.term.focus();
766
+ }
767
+ });
768
+ }
769
+
770
+ function requestNewTab() {
771
+ var cols = 80;
772
+ var rows = 24;
773
+ var active = tabs[activeSessionId];
774
+ if (active) {
775
+ cols = active.term.cols;
776
+ rows = active.term.rows;
777
+ }
778
+ controlSend({ type: 'create', cols: cols, rows: rows });
779
+ }
780
+
781
+ function closeActiveTab() {
782
+ if (activeSessionId) {
783
+ controlSend({ type: 'kill', sessionId: activeSessionId });
784
+ }
785
+ }
786
+
787
+ newTabBtn.addEventListener('click', function () {
788
+ requestNewTab();
789
+ });
790
+
791
+ window.addEventListener('resize', updateLayout);
792
+
793
+ document.addEventListener('keydown', function (e) {
794
+ if (!e.ctrlKey && !e.metaKey) return;
795
+
796
+ if (e.shiftKey && e.key === 't') {
797
+ e.preventDefault();
798
+ requestNewTab();
799
+ } else if (e.shiftKey && e.key === 'w') {
800
+ e.preventDefault();
801
+ closeActiveTab();
802
+ } else if (e.shiftKey && e.key === 'g') {
803
+ e.preventDefault();
804
+ setGridMode(!gridMode);
805
+ } else if (e.key === 'Tab' && e.ctrlKey) {
806
+ e.preventDefault();
807
+ if (tabOrder.length < 2) return;
808
+ var curIdx = tabOrder.indexOf(activeSessionId);
809
+ var nextIdx = e.shiftKey
810
+ ? (curIdx - 1 + tabOrder.length) % tabOrder.length
811
+ : (curIdx + 1) % tabOrder.length;
812
+ activateTab(tabOrder[nextIdx]);
813
+ }
814
+ });
815
+
816
+ // ---- Virtual Keyboard ----
817
+ var isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);
818
+ var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
819
+
820
+ var vkToggle = document.getElementById('vk-toggle');
821
+ var vkBar = document.getElementById('vk-bar');
822
+ var vkRowMod = document.getElementById('vk-row-modifiers');
823
+ var vkRowKeys = document.getElementById('vk-row-keys');
824
+
825
+ var modState = { ctrl: false, alt: false, shift: false, meta: false };
826
+
827
+ var modifiers = [
828
+ { key: 'Esc', id: null },
829
+ { key: 'Tab', id: null },
830
+ { key: 'Ctrl', id: 'ctrl', cls: 'modifier' },
831
+ { key: 'Alt', id: 'alt', cls: 'modifier' },
832
+ { key: 'Shift', id: 'shift', cls: 'modifier' },
833
+ { key: isMac ? 'Cmd' : 'Win', id: 'meta', cls: 'modifier' },
834
+ ];
835
+
836
+ var extraKeys = [
837
+ { label: '↑', seq: '\x1b[A' },
838
+ { label: '↓', seq: '\x1b[B' },
839
+ { label: '←', seq: '\x1b[D' },
840
+ { label: '→', seq: '\x1b[C' },
841
+ { label: 'Home', seq: '\x1b[H' },
842
+ { label: 'End', seq: '\x1b[F' },
843
+ { label: 'PgUp', seq: '\x1b[5~' },
844
+ { label: 'PgDn', seq: '\x1b[6~' },
845
+ { label: 'Del', seq: '\x1b[3~' },
846
+ { label: 'Ins', seq: '\x1b[2~' },
847
+ ];
848
+
849
+ function getActiveTab() {
850
+ return tabs[activeSessionId];
851
+ }
852
+
853
+ function sendToTerminal(data) {
854
+ var tab = getActiveTab();
855
+ if (tab && tab.ws && tab.ws.readyState === WebSocket.OPEN) {
856
+ tab.ws.send(JSON.stringify({ type: 'input', data: data }));
857
+ }
858
+ }
859
+
860
+ function clearModifiers() {
861
+ for (var k in modState) {
862
+ if (modState[k] === true) {
863
+ modState[k] = false;
864
+ }
865
+ }
866
+ updateModButtons();
867
+ }
868
+
869
+ function updateModButtons() {
870
+ var btns = vkRowMod.querySelectorAll('.modifier');
871
+ btns.forEach(function (btn) {
872
+ var id = btn.dataset.mod;
873
+ btn.classList.toggle('held', modState[id] === true);
874
+ btn.classList.toggle('locked', modState[id] === 'locked');
875
+ });
876
+ }
877
+
878
+ function modifiedSeq(baseSeq) {
879
+ var m = 1;
880
+ if (modState.shift) m += 1;
881
+ if (modState.alt) m += 2;
882
+ if (modState.ctrl) m += 4;
883
+ if (modState.meta) m += 8;
884
+ if (m === 1) return baseSeq;
885
+
886
+ if (baseSeq.length === 3 && baseSeq[0] === '\x1b' && baseSeq[1] === '[') {
887
+ return '\x1b[1;' + m + baseSeq[2];
888
+ }
889
+ var tilde = baseSeq.match(/^\x1b\[(\d+)~$/);
890
+ if (tilde) {
891
+ return '\x1b[' + tilde[1] + ';' + m + '~';
892
+ }
893
+ if (baseSeq.length === 3 && baseSeq[0] === '\x1b' && baseSeq[1] === '[') {
894
+ return '\x1b[1;' + m + baseSeq[2];
895
+ }
896
+ return baseSeq;
897
+ }
898
+
899
+ // Build modifier row
900
+ modifiers.forEach(function (def) {
901
+ var btn = document.createElement('div');
902
+ btn.className = 'vk-key' + (def.cls ? ' ' + def.cls : '') + (def.key === 'Tab' || def.key === 'Esc' ? '' : '');
903
+ btn.textContent = def.key;
904
+ if (def.id) {
905
+ btn.dataset.mod = def.id;
906
+ var lastTap = 0;
907
+ btn.addEventListener('pointerdown', function (e) {
908
+ e.preventDefault();
909
+ var now = Date.now();
910
+ if (now - lastTap < 300) {
911
+ modState[def.id] = modState[def.id] === 'locked' ? false : 'locked';
912
+ } else {
913
+ modState[def.id] = modState[def.id] ? false : true;
914
+ }
915
+ lastTap = now;
916
+ updateModButtons();
917
+ });
918
+ } else if (def.key === 'Esc') {
919
+ btn.addEventListener('pointerdown', function (e) {
920
+ e.preventDefault();
921
+ sendToTerminal('\x1b');
922
+ clearModifiers();
923
+ });
924
+ } else if (def.key === 'Tab') {
925
+ btn.addEventListener('pointerdown', function (e) {
926
+ e.preventDefault();
927
+ if (modState.shift) {
928
+ sendToTerminal('\x1b[Z');
929
+ } else {
930
+ sendToTerminal('\t');
931
+ }
932
+ clearModifiers();
933
+ });
934
+ }
935
+ vkRowMod.appendChild(btn);
936
+ });
937
+
938
+ // Build action keys row
939
+ extraKeys.forEach(function (def) {
940
+ var btn = document.createElement('div');
941
+ var isArrow = def.label.length === 1 && '↑↓←→'.indexOf(def.label) !== -1;
942
+ btn.className = 'vk-key' + (isArrow ? ' arrow' : '');
943
+ btn.textContent = def.label;
944
+ btn.addEventListener('pointerdown', function (e) {
945
+ e.preventDefault();
946
+ sendToTerminal(modifiedSeq(def.seq));
947
+ clearModifiers();
948
+ });
949
+ vkRowKeys.appendChild(btn);
950
+ });
951
+
952
+ // Toggle visibility
953
+ function setVkVisible(visible) {
954
+ document.body.classList.toggle('vk-visible', visible);
955
+ vkToggle.classList.toggle('active', visible);
956
+ localStorage.setItem('ccweb_vk', visible ? '1' : '0');
957
+ setTimeout(updateLayout, 50);
958
+ }
959
+
960
+ vkToggle.addEventListener('click', function () {
961
+ setVkVisible(!document.body.classList.contains('vk-visible'));
962
+ });
963
+
964
+ // --- visualViewport ---
965
+ function refitActiveTab() {
966
+ if (gridMode) {
967
+ refitAllTabs();
968
+ return;
969
+ }
970
+ var tab = getActiveTab();
971
+ if (tab && tab.pane.classList.contains('active')) {
972
+ tab.fitAddon.fit();
973
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
974
+ tab.ws.send(JSON.stringify({ type: 'resize', cols: tab.term.cols, rows: tab.term.rows }));
975
+ }
976
+ }
977
+ }
978
+
979
+ var lastKbOffset = 0;
980
+
981
+ function updateLayout() {
982
+ var vv = window.visualViewport;
983
+ var keyboardOffset = 0;
984
+
985
+ if (vv) {
986
+ keyboardOffset = Math.round(window.innerHeight - vv.height - vv.offsetTop);
987
+ if (keyboardOffset < 0) keyboardOffset = 0;
988
+ }
989
+
990
+ var translate = keyboardOffset > 0
991
+ ? 'translateY(-' + keyboardOffset + 'px)' : '';
992
+ vkBar.style.transform = translate;
993
+ vkToggle.style.transform = translate;
994
+ var vkHeight = document.body.classList.contains('vk-visible') ? vkBar.offsetHeight : 0;
995
+ terminalsArea.style.bottom = (keyboardOffset + vkHeight) + 'px';
996
+
997
+ if (keyboardOffset !== lastKbOffset) {
998
+ lastKbOffset = keyboardOffset;
999
+ }
1000
+
1001
+ refitActiveTab();
1002
+ }
1003
+
1004
+ if (window.visualViewport) {
1005
+ var rafPending = false;
1006
+ function onViewportChange() {
1007
+ if (!rafPending) {
1008
+ rafPending = true;
1009
+ requestAnimationFrame(function () {
1010
+ rafPending = false;
1011
+ updateLayout();
1012
+ });
1013
+ }
1014
+ }
1015
+ window.visualViewport.addEventListener('resize', onViewportChange);
1016
+ window.visualViewport.addEventListener('scroll', onViewportChange);
1017
+ }
1018
+
1019
+ // Auto-show on touch devices, restore preference otherwise
1020
+ var vkPref = localStorage.getItem('ccweb_vk');
1021
+ if (vkPref !== null) {
1022
+ setVkVisible(vkPref === '1');
1023
+ } else if (isTouchDevice) {
1024
+ setVkVisible(true);
1025
+ }
1026
+
1027
+ // Restore grid mode preference
1028
+ var gridPref = localStorage.getItem('ccweb_grid');
1029
+ if (gridPref === '1') {
1030
+ setGridMode(true);
1031
+ }
1032
+
1033
+ // Start control connection
1034
+ connectControl();
1035
+ })();
1036
+ </script>
1037
+ </body>
1038
+ </html>