@leverageaiapps/gogogo 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.
- package/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/cloudflare-tunnel.d.ts +9 -0
- package/dist/cloudflare-tunnel.d.ts.map +1 -0
- package/dist/cloudflare-tunnel.js +209 -0
- package/dist/cloudflare-tunnel.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/pty.d.ts +22 -0
- package/dist/pty.d.ts.map +1 -0
- package/dist/pty.js +172 -0
- package/dist/pty.js.map +1 -0
- package/dist/session.d.ts +2 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +246 -0
- package/dist/session.js.map +1 -0
- package/dist/web-server.d.ts +3 -0
- package/dist/web-server.d.ts.map +1 -0
- package/dist/web-server.js +447 -0
- package/dist/web-server.js.map +1 -0
- package/package.json +67 -0
- package/public/index.html +509 -0
- package/public/js/terminal.js +729 -0
- package/scripts/postinstall.js +66 -0
- package/scripts/verify-install.js +124 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
// Clean token from URL after cookie-based auth is established
|
|
2
|
+
if (window.location.search.includes('token=')) {
|
|
3
|
+
const url = new URL(window.location);
|
|
4
|
+
url.searchParams.delete('token');
|
|
5
|
+
history.replaceState(null, '', url.pathname + url.search);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Theme management
|
|
9
|
+
const darkTheme = { background: '#0a0a0a', foreground: '#ededed', cursor: '#ededed', selectionBackground: '#3b82f644' };
|
|
10
|
+
const lightTheme = { background: '#f5f5f5', foreground: '#1a1a1a', cursor: '#1a1a1a', selectionBackground: '#2563eb44' };
|
|
11
|
+
let isLightTheme = (() => {
|
|
12
|
+
const saved = localStorage.getItem('theme');
|
|
13
|
+
if (saved) return saved === 'light';
|
|
14
|
+
try { return window.matchMedia('(prefers-color-scheme: light)').matches; } catch(e) { return false; }
|
|
15
|
+
})();
|
|
16
|
+
|
|
17
|
+
window.term = new Terminal({
|
|
18
|
+
cursorBlink: true,
|
|
19
|
+
fontSize: 14,
|
|
20
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
21
|
+
smoothScrollDuration: 120,
|
|
22
|
+
theme: isLightTheme ? lightTheme : darkTheme,
|
|
23
|
+
scrollback: 10000,
|
|
24
|
+
allowTransparency: false,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const fitAddon = new FitAddon.FitAddon();
|
|
28
|
+
term.loadAddon(fitAddon);
|
|
29
|
+
term.open(document.getElementById('terminal-container'));
|
|
30
|
+
try { fitAddon.fit(); } catch(e) {}
|
|
31
|
+
|
|
32
|
+
const statusDot = document.getElementById('status-dot');
|
|
33
|
+
const input = document.getElementById('input');
|
|
34
|
+
const scrollBtn = document.getElementById('scroll-to-bottom');
|
|
35
|
+
const specialKeysBtn = document.getElementById('special-keys-btn');
|
|
36
|
+
const specialKeysPopup = document.getElementById('special-keys-popup');
|
|
37
|
+
|
|
38
|
+
let ws = null;
|
|
39
|
+
let reconnectAttempts = 0;
|
|
40
|
+
const MAX_RECONNECT_ATTEMPTS = 3;
|
|
41
|
+
let isUserScrolling = false;
|
|
42
|
+
let lastSeq = 0; // track last received sequence number for delta sync
|
|
43
|
+
let nativeInputMode = false;
|
|
44
|
+
|
|
45
|
+
// Apply saved theme on load
|
|
46
|
+
function applyTheme(light) {
|
|
47
|
+
const root = document.documentElement;
|
|
48
|
+
const sunIcon = document.getElementById('theme-icon-sun');
|
|
49
|
+
const moonIcon = document.getElementById('theme-icon-moon');
|
|
50
|
+
if (light) {
|
|
51
|
+
root.classList.add('light');
|
|
52
|
+
term.options.theme = lightTheme;
|
|
53
|
+
sunIcon.style.display = '';
|
|
54
|
+
moonIcon.style.display = 'none';
|
|
55
|
+
} else {
|
|
56
|
+
root.classList.remove('light');
|
|
57
|
+
term.options.theme = darkTheme;
|
|
58
|
+
sunIcon.style.display = 'none';
|
|
59
|
+
moonIcon.style.display = '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
applyTheme(isLightTheme);
|
|
63
|
+
|
|
64
|
+
document.getElementById('theme-toggle').addEventListener('click', () => {
|
|
65
|
+
isLightTheme = !isLightTheme;
|
|
66
|
+
localStorage.setItem('theme', isLightTheme ? 'light' : 'dark');
|
|
67
|
+
applyTheme(isLightTheme);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Follow system theme changes when user hasn't manually overridden
|
|
71
|
+
try {
|
|
72
|
+
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => {
|
|
73
|
+
if (!localStorage.getItem('theme')) {
|
|
74
|
+
isLightTheme = e.matches;
|
|
75
|
+
applyTheme(isLightTheme);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
} catch(e) {}
|
|
79
|
+
|
|
80
|
+
// Title sync — follow terminal escape sequences
|
|
81
|
+
const DEFAULT_TITLE = 'gogogo Terminal';
|
|
82
|
+
let terminalTitle = DEFAULT_TITLE;
|
|
83
|
+
term.onTitleChange((title) => {
|
|
84
|
+
// Strip leading emoji — favicon handles branding
|
|
85
|
+
terminalTitle = (title || DEFAULT_TITLE).replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\uFE0F]\s*/u, '');
|
|
86
|
+
document.title = terminalTitle;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Touch scrolling state
|
|
90
|
+
const terminalContainer = document.getElementById('terminal-container');
|
|
91
|
+
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
92
|
+
|
|
93
|
+
// Dynamic input area height -> adjust terminal container bottom
|
|
94
|
+
const inputArea = document.getElementById('input-area');
|
|
95
|
+
function updateTerminalBottom() {
|
|
96
|
+
const h = inputArea.offsetHeight;
|
|
97
|
+
terminalContainer.style.bottom = h + 'px';
|
|
98
|
+
scrollBtn.style.bottom = (h + 10) + 'px';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Initialize touch scrolling for mobile devices
|
|
102
|
+
if (isTouchDevice) {
|
|
103
|
+
initTouchScrolling(terminalContainer, () => { isUserScrolling = true; });
|
|
104
|
+
} else {
|
|
105
|
+
// Desktop: click on terminal activates native input
|
|
106
|
+
terminalContainer.addEventListener('click', (e) => {
|
|
107
|
+
if (e.target.closest('#scroll-to-bottom')) return;
|
|
108
|
+
activateNativeInput();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Switch back to input box mode when input is focused
|
|
113
|
+
input.addEventListener('focus', () => {
|
|
114
|
+
activateInputBoxMode();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Touch scrolling implementation for smooth mobile scrolling
|
|
118
|
+
function initTouchScrolling(container, onScrollStart) {
|
|
119
|
+
const touchState = {
|
|
120
|
+
startY: 0, lastY: 0, lastTime: 0,
|
|
121
|
+
velocity: 0, identifier: null,
|
|
122
|
+
touching: false, velocityHistory: [],
|
|
123
|
+
accumulator: 0, inertiaId: null
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Create touch overlay
|
|
127
|
+
const overlay = createTouchOverlay(container);
|
|
128
|
+
|
|
129
|
+
// Attach event handlers
|
|
130
|
+
overlay.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
131
|
+
overlay.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
132
|
+
overlay.addEventListener('touchend', handleTouchEnd, { passive: false });
|
|
133
|
+
overlay.addEventListener('touchcancel', handleTouchCancel, { passive: false });
|
|
134
|
+
|
|
135
|
+
// Prevent conflicts with input area
|
|
136
|
+
const inputArea = document.getElementById('input-area');
|
|
137
|
+
if (inputArea) {
|
|
138
|
+
inputArea.addEventListener('touchstart', e => e.stopPropagation(), { passive: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createTouchOverlay(parent) {
|
|
142
|
+
const div = document.createElement('div');
|
|
143
|
+
Object.assign(div.style, {
|
|
144
|
+
position: 'absolute', top: '0', left: '0', right: '0', bottom: '0',
|
|
145
|
+
zIndex: '1', touchAction: 'none', webkitTouchCallout: 'none',
|
|
146
|
+
webkitUserSelect: 'none', userSelect: 'none', pointerEvents: 'auto'
|
|
147
|
+
});
|
|
148
|
+
parent.appendChild(div);
|
|
149
|
+
return div;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Use xterm scrollLines API for v6 virtual scrolling
|
|
153
|
+
let lineAccumulator = 0;
|
|
154
|
+
function performScroll(deltaY) {
|
|
155
|
+
lineAccumulator += deltaY;
|
|
156
|
+
// Approximate line height for current font size
|
|
157
|
+
const lineHeight = 17;
|
|
158
|
+
const lines = Math.trunc(lineAccumulator / lineHeight);
|
|
159
|
+
if (lines !== 0) {
|
|
160
|
+
term.scrollLines(lines);
|
|
161
|
+
lineAccumulator -= lines * lineHeight;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function handleTouchStart(e) {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
cancelInertia();
|
|
168
|
+
touchState.accumulator = 0;
|
|
169
|
+
lineAccumulator = 0;
|
|
170
|
+
|
|
171
|
+
if (e.touches.length > 0) {
|
|
172
|
+
const touch = e.touches[0];
|
|
173
|
+
Object.assign(touchState, {
|
|
174
|
+
identifier: touch.identifier,
|
|
175
|
+
startX: touch.clientX,
|
|
176
|
+
startY: touch.clientY,
|
|
177
|
+
lastY: touch.clientY,
|
|
178
|
+
lastTime: performance.now(),
|
|
179
|
+
startTime: performance.now(),
|
|
180
|
+
velocity: 0,
|
|
181
|
+
velocityHistory: [],
|
|
182
|
+
touching: true,
|
|
183
|
+
didMove: false
|
|
184
|
+
});
|
|
185
|
+
onScrollStart();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleTouchMove(e) {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
if (!touchState.touching || e.touches.length === 0) return;
|
|
192
|
+
|
|
193
|
+
const touch = findTrackedTouch(e.touches) || e.touches[0];
|
|
194
|
+
const currentY = touch.clientY;
|
|
195
|
+
const deltaY = touchState.lastY - currentY;
|
|
196
|
+
const currentTime = performance.now();
|
|
197
|
+
const timeDelta = Math.max(1, currentTime - touchState.lastTime);
|
|
198
|
+
|
|
199
|
+
// Track if finger moved significantly (distinguishes tap from scroll)
|
|
200
|
+
if (!touchState.didMove) {
|
|
201
|
+
const dx = Math.abs(touch.clientX - touchState.startX);
|
|
202
|
+
const dy = Math.abs(currentY - touchState.startY);
|
|
203
|
+
if (dx > 10 || dy > 10) {
|
|
204
|
+
touchState.didMove = true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Update velocity
|
|
209
|
+
updateVelocity(deltaY / timeDelta);
|
|
210
|
+
|
|
211
|
+
touchState.lastY = currentY;
|
|
212
|
+
touchState.lastTime = currentTime;
|
|
213
|
+
touchState.accumulator += deltaY;
|
|
214
|
+
|
|
215
|
+
// Apply scroll when threshold reached
|
|
216
|
+
if (Math.abs(touchState.accumulator) >= 0.5) {
|
|
217
|
+
performScroll(touchState.accumulator * 1.8);
|
|
218
|
+
touchState.accumulator = touchState.accumulator % 0.5;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function handleTouchEnd(e) {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
if (!isTouchEnded(e.touches)) return;
|
|
225
|
+
|
|
226
|
+
const touchDuration = performance.now() - touchState.startTime;
|
|
227
|
+
const wasTap = !touchState.didMove && touchDuration < 300;
|
|
228
|
+
|
|
229
|
+
touchState.touching = false;
|
|
230
|
+
touchState.identifier = null;
|
|
231
|
+
|
|
232
|
+
if (wasTap) {
|
|
233
|
+
// Short tap with no movement -> activate native input
|
|
234
|
+
// Must blur input first to dismiss its caret on mobile
|
|
235
|
+
input.blur();
|
|
236
|
+
// Must call focus synchronously within user gesture for mobile keyboard
|
|
237
|
+
term.focus();
|
|
238
|
+
nativeInputMode = true;
|
|
239
|
+
document.getElementById('input-area').classList.add('native-mode');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Apply remaining scroll
|
|
244
|
+
if (Math.abs(touchState.accumulator) > 0) {
|
|
245
|
+
performScroll(touchState.accumulator * 1.8);
|
|
246
|
+
touchState.accumulator = 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Start inertia if needed
|
|
250
|
+
if (Math.abs(touchState.velocity) > 0.01) {
|
|
251
|
+
startInertia();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function handleTouchCancel(e) {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
resetTouchState();
|
|
258
|
+
cancelInertia();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function findTrackedTouch(touches) {
|
|
262
|
+
for (let i = 0; i < touches.length; i++) {
|
|
263
|
+
if (touches[i].identifier === touchState.identifier) {
|
|
264
|
+
return touches[i];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isTouchEnded(touches) {
|
|
271
|
+
return !findTrackedTouch(touches);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function updateVelocity(instant) {
|
|
275
|
+
touchState.velocityHistory.push(instant);
|
|
276
|
+
if (touchState.velocityHistory.length > 5) {
|
|
277
|
+
touchState.velocityHistory.shift();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Calculate weighted average
|
|
281
|
+
let weightedSum = 0, totalWeight = 0;
|
|
282
|
+
touchState.velocityHistory.forEach((v, i) => {
|
|
283
|
+
const weight = i + 1;
|
|
284
|
+
weightedSum += v * weight;
|
|
285
|
+
totalWeight += weight;
|
|
286
|
+
});
|
|
287
|
+
touchState.velocity = totalWeight ? weightedSum / totalWeight : 0;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function startInertia() {
|
|
291
|
+
const friction = 0.95;
|
|
292
|
+
const minVelocity = 0.01;
|
|
293
|
+
|
|
294
|
+
function animate() {
|
|
295
|
+
if (Math.abs(touchState.velocity) < minVelocity || touchState.touching) {
|
|
296
|
+
touchState.inertiaId = null;
|
|
297
|
+
touchState.velocity = 0;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
performScroll(touchState.velocity * 25);
|
|
302
|
+
touchState.velocity *= friction;
|
|
303
|
+
touchState.inertiaId = requestAnimationFrame(animate);
|
|
304
|
+
}
|
|
305
|
+
animate();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function cancelInertia() {
|
|
309
|
+
if (touchState.inertiaId) {
|
|
310
|
+
cancelAnimationFrame(touchState.inertiaId);
|
|
311
|
+
touchState.inertiaId = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function resetTouchState() {
|
|
316
|
+
Object.assign(touchState, {
|
|
317
|
+
touching: false, identifier: null,
|
|
318
|
+
velocity: 0, velocityHistory: [],
|
|
319
|
+
accumulator: 0
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function setInputEnabled(enabled) {
|
|
325
|
+
input.disabled = !enabled;
|
|
326
|
+
input.style.opacity = enabled ? '1' : '0.5';
|
|
327
|
+
input.style.cursor = enabled ? 'text' : 'not-allowed';
|
|
328
|
+
if (!enabled) {
|
|
329
|
+
input.placeholder = 'Reconnecting...';
|
|
330
|
+
} else {
|
|
331
|
+
input.placeholder = 'Type command...';
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function updateStatus(state) {
|
|
336
|
+
statusDot.className = '';
|
|
337
|
+
if (state === 'disconnected') {
|
|
338
|
+
statusDot.classList.add('disconnected');
|
|
339
|
+
} else if (state === 'connecting') {
|
|
340
|
+
statusDot.classList.add('connecting');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function connect() {
|
|
345
|
+
updateStatus('connecting');
|
|
346
|
+
// WebSocket will automatically include cookies with the request
|
|
347
|
+
const wsUrl = location.protocol.replace('http', 'ws') + '//' + location.host + '/ws';
|
|
348
|
+
ws = new WebSocket(wsUrl);
|
|
349
|
+
|
|
350
|
+
ws.onopen = () => {
|
|
351
|
+
console.log('WebSocket connected');
|
|
352
|
+
updateStatus('connected');
|
|
353
|
+
reconnectAttempts = 0;
|
|
354
|
+
try { fitAddon.fit(); } catch(e) {}
|
|
355
|
+
ws.send(JSON.stringify({ type: 'sync', lastSeq }));
|
|
356
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
ws.onclose = () => {
|
|
360
|
+
console.log('WebSocket closed');
|
|
361
|
+
updateStatus('disconnected');
|
|
362
|
+
ws = null;
|
|
363
|
+
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
364
|
+
reconnectAttempts++;
|
|
365
|
+
setTimeout(() => {
|
|
366
|
+
connect();
|
|
367
|
+
}, 500);
|
|
368
|
+
} else {
|
|
369
|
+
setInputEnabled(false);
|
|
370
|
+
input.placeholder = 'Connection failed. Refresh page.';
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
ws.onerror = (err) => {
|
|
375
|
+
console.log('WebSocket error');
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
ws.onmessage = (e) => {
|
|
379
|
+
let msg;
|
|
380
|
+
try { msg = JSON.parse(e.data); } catch { return; }
|
|
381
|
+
if (msg.type === 'output') {
|
|
382
|
+
if (msg.seq != null) lastSeq = msg.seq;
|
|
383
|
+
if (typeof msg.data === 'string') term.write(msg.data);
|
|
384
|
+
checkScrollPosition();
|
|
385
|
+
} else if (msg.type === 'history') {
|
|
386
|
+
if (msg.lastSeq != null) lastSeq = msg.lastSeq;
|
|
387
|
+
term.clear();
|
|
388
|
+
if (Array.isArray(msg.data)) msg.data.forEach(d => term.write(d));
|
|
389
|
+
setInputEnabled(true);
|
|
390
|
+
setTimeout(() => { restoreScrollState(); }, 100);
|
|
391
|
+
} else if (msg.type === 'history-delta') {
|
|
392
|
+
if (msg.lastSeq != null) lastSeq = msg.lastSeq;
|
|
393
|
+
if (Array.isArray(msg.data)) msg.data.forEach(d => term.write(d));
|
|
394
|
+
setInputEnabled(true);
|
|
395
|
+
checkScrollPosition();
|
|
396
|
+
} else if (msg.type === 'exit') {
|
|
397
|
+
term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
|
|
398
|
+
setInputEnabled(false);
|
|
399
|
+
input.placeholder = 'Process exited. Refresh page to restart.';
|
|
400
|
+
} else if (msg.type === 'image_uploaded') {
|
|
401
|
+
if (msg.error) {
|
|
402
|
+
console.error('[Image] Upload error:', msg.error);
|
|
403
|
+
} else if (msg.path) {
|
|
404
|
+
if (nativeInputMode) {
|
|
405
|
+
if (ws && ws.readyState === 1) {
|
|
406
|
+
ws.send(JSON.stringify({ type: 'input', data: msg.path }));
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
const pos = input.selectionStart ?? input.value.length;
|
|
410
|
+
const before = input.value.slice(0, pos);
|
|
411
|
+
const after = input.value.slice(pos);
|
|
412
|
+
const needSpace = before.length > 0 && !before.endsWith(' ');
|
|
413
|
+
input.value = before + (needSpace ? ' ' : '') + msg.path + (after.length > 0 && !after.startsWith(' ') ? ' ' : '') + after;
|
|
414
|
+
input.style.height = 'auto';
|
|
415
|
+
input.style.height = input.scrollHeight + 'px';
|
|
416
|
+
updateTerminalBottom();
|
|
417
|
+
input.focus();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Input handling - must send text and Enter key separately for Claude Code to work
|
|
425
|
+
input.addEventListener('keydown', (e) => {
|
|
426
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
427
|
+
e.preventDefault();
|
|
428
|
+
if (ws && ws.readyState === 1) {
|
|
429
|
+
const cmd = input.value;
|
|
430
|
+
// Clear via execCommand so browser preserves undo history (Ctrl+Z to restore)
|
|
431
|
+
input.focus();
|
|
432
|
+
input.select();
|
|
433
|
+
document.execCommand('delete');
|
|
434
|
+
input.style.height = 'auto';
|
|
435
|
+
updateTerminalBottom();
|
|
436
|
+
if (cmd) {
|
|
437
|
+
// Send text first, then Enter key separately after delay
|
|
438
|
+
ws.send(JSON.stringify({ type: 'input', data: cmd }));
|
|
439
|
+
setTimeout(() => {
|
|
440
|
+
if (ws && ws.readyState === 1) {
|
|
441
|
+
ws.send(JSON.stringify({ type: 'input', data: String.fromCharCode(13) }));
|
|
442
|
+
}
|
|
443
|
+
}, 50);
|
|
444
|
+
} else {
|
|
445
|
+
// Just send Enter if empty
|
|
446
|
+
ws.send(JSON.stringify({ type: 'input', data: String.fromCharCode(13) }));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// Auto-resize textarea and adjust terminal container (no terminal refit)
|
|
453
|
+
function resizeInput() {
|
|
454
|
+
input.style.height = 'auto';
|
|
455
|
+
input.style.height = input.scrollHeight + 'px';
|
|
456
|
+
updateTerminalBottom();
|
|
457
|
+
}
|
|
458
|
+
input.addEventListener('input', resizeInput);
|
|
459
|
+
|
|
460
|
+
// Shared image upload helper (used by both input textarea and native mode paste)
|
|
461
|
+
const MAX_CLIENT_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
462
|
+
function uploadImageFile(file) {
|
|
463
|
+
if (!file) return;
|
|
464
|
+
if (file.size > MAX_CLIENT_IMAGE_SIZE) {
|
|
465
|
+
console.error('[Image] File too large (max 5MB):', file.name);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const reader = new FileReader();
|
|
469
|
+
reader.onload = () => {
|
|
470
|
+
const parts = reader.result.split(',');
|
|
471
|
+
const base64 = parts.length > 1 ? parts[1] : '';
|
|
472
|
+
if (!base64) return;
|
|
473
|
+
const ext = file.type.split('/')[1] || 'png';
|
|
474
|
+
const filename = (file.name && file.name !== 'image.png') ? file.name : 'clipboard-' + Date.now() + '.' + ext;
|
|
475
|
+
if (ws && ws.readyState === 1) {
|
|
476
|
+
ws.send(JSON.stringify({ type: 'image_upload', data: base64, filename: filename }));
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
reader.onerror = () => {
|
|
480
|
+
console.error('[Image] Failed to read file:', file.name);
|
|
481
|
+
};
|
|
482
|
+
reader.readAsDataURL(file);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Paste image from clipboard
|
|
486
|
+
input.addEventListener('paste', (e) => {
|
|
487
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
488
|
+
if (!items) return;
|
|
489
|
+
for (let i = 0; i < items.length; i++) {
|
|
490
|
+
if (items[i].type.indexOf('image/') === 0) {
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
uploadImageFile(items[i].getAsFile());
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// File upload button (mobile fallback for image paste)
|
|
499
|
+
const fileUploadBtn = document.getElementById('file-upload-btn');
|
|
500
|
+
const fileUploadInput = document.getElementById('file-upload-input');
|
|
501
|
+
if (fileUploadBtn && fileUploadInput) {
|
|
502
|
+
fileUploadBtn.addEventListener('click', () => fileUploadInput.click());
|
|
503
|
+
fileUploadInput.addEventListener('change', () => {
|
|
504
|
+
const file = fileUploadInput.files[0];
|
|
505
|
+
if (file) uploadImageFile(file);
|
|
506
|
+
fileUploadInput.value = '';
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Special keys handling
|
|
511
|
+
specialKeysBtn.addEventListener('click', (e) => {
|
|
512
|
+
e.stopPropagation();
|
|
513
|
+
specialKeysPopup.classList.toggle('show');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
document.addEventListener('click', (e) => {
|
|
517
|
+
if (!specialKeysPopup.contains(e.target) && e.target !== specialKeysBtn) {
|
|
518
|
+
specialKeysPopup.classList.remove('show');
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
document.querySelectorAll('.special-key').forEach(btn => {
|
|
523
|
+
btn.addEventListener('click', () => {
|
|
524
|
+
const key = btn.getAttribute('data-key');
|
|
525
|
+
handleSpecialKey(key);
|
|
526
|
+
specialKeysPopup.classList.remove('show');
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
function handleSpecialKey(key) {
|
|
531
|
+
if (!ws || ws.readyState !== 1) return;
|
|
532
|
+
|
|
533
|
+
const keyMap = {
|
|
534
|
+
'Escape': '\x1b',
|
|
535
|
+
'Tab': '\t',
|
|
536
|
+
'Up': '\x1b[A',
|
|
537
|
+
'Down': '\x1b[B',
|
|
538
|
+
'Left': '\x1b[D',
|
|
539
|
+
'Right': '\x1b[C',
|
|
540
|
+
'Ctrl+C': '\x03',
|
|
541
|
+
'Ctrl+D': '\x04',
|
|
542
|
+
'Ctrl+Z': '\x1a',
|
|
543
|
+
'Ctrl+L': '\x0c',
|
|
544
|
+
'Home': '\x1b[H',
|
|
545
|
+
'End': '\x1b[F',
|
|
546
|
+
'PageUp': '\x1b[5~',
|
|
547
|
+
'PageDown': '\x1b[6~',
|
|
548
|
+
'F1': '\x1bOP',
|
|
549
|
+
'F2': '\x1bOQ',
|
|
550
|
+
'F3': '\x1bOR',
|
|
551
|
+
'F4': '\x1bOS'
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
if (keyMap[key]) {
|
|
555
|
+
ws.send(JSON.stringify({ type: 'input', data: keyMap[key] }));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Scroll handling - use xterm buffer API (works with v6's virtual scrolling)
|
|
560
|
+
function checkScrollPosition() {
|
|
561
|
+
const buf = term.buffer.active;
|
|
562
|
+
const atBottom = buf.viewportY >= buf.baseY;
|
|
563
|
+
|
|
564
|
+
if (atBottom) {
|
|
565
|
+
scrollBtn.classList.remove('visible');
|
|
566
|
+
isUserScrolling = false;
|
|
567
|
+
} else {
|
|
568
|
+
scrollBtn.classList.add('visible');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Save scroll state for reconnection recovery
|
|
572
|
+
const state = {
|
|
573
|
+
viewportY: buf.viewportY,
|
|
574
|
+
baseY: buf.baseY,
|
|
575
|
+
atBottom: atBottom
|
|
576
|
+
};
|
|
577
|
+
sessionStorage.setItem('scrollState', JSON.stringify(state));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function restoreScrollState() {
|
|
581
|
+
const raw = sessionStorage.getItem('scrollState');
|
|
582
|
+
if (!raw) {
|
|
583
|
+
// First load - scroll to bottom
|
|
584
|
+
term.scrollToBottom();
|
|
585
|
+
isUserScrolling = false;
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
const state = JSON.parse(raw);
|
|
590
|
+
if (state.atBottom) {
|
|
591
|
+
term.scrollToBottom();
|
|
592
|
+
isUserScrolling = false;
|
|
593
|
+
} else {
|
|
594
|
+
// Restore approximate position by offset from bottom
|
|
595
|
+
const buf = term.buffer.active;
|
|
596
|
+
const offsetFromBottom = state.baseY - state.viewportY;
|
|
597
|
+
const targetLine = Math.max(0, buf.baseY - offsetFromBottom);
|
|
598
|
+
term.scrollToLine(targetLine);
|
|
599
|
+
isUserScrolling = true;
|
|
600
|
+
}
|
|
601
|
+
checkScrollPosition();
|
|
602
|
+
} catch (e) {
|
|
603
|
+
term.scrollToBottom();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
scrollBtn.addEventListener('click', () => {
|
|
608
|
+
term.scrollToBottom();
|
|
609
|
+
isUserScrolling = false;
|
|
610
|
+
scrollBtn.classList.remove('visible');
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
// Monitor scroll via xterm API
|
|
614
|
+
term.onScroll(() => checkScrollPosition());
|
|
615
|
+
// Wheel event as supplement (user scrolling may not trigger term.onScroll immediately)
|
|
616
|
+
terminalContainer.addEventListener('wheel', () => {
|
|
617
|
+
setTimeout(checkScrollPosition, 50);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// Window resize
|
|
621
|
+
let resizeTimeout;
|
|
622
|
+
window.addEventListener('resize', () => {
|
|
623
|
+
clearTimeout(resizeTimeout);
|
|
624
|
+
resizeTimeout = setTimeout(() => {
|
|
625
|
+
updateTerminalBottom();
|
|
626
|
+
try { fitAddon.fit(); } catch(e) {}
|
|
627
|
+
if (ws && ws.readyState === 1) {
|
|
628
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
629
|
+
}
|
|
630
|
+
}, 100);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// Initial layout
|
|
634
|
+
updateTerminalBottom();
|
|
635
|
+
|
|
636
|
+
// Mobile keyboard compensation for native input mode
|
|
637
|
+
// On iOS Safari, position:fixed body keeps full layout viewport height when
|
|
638
|
+
// the keyboard opens, so the keyboard covers the terminal bottom (cursor area).
|
|
639
|
+
// We use transform:translateY to shift the terminal container upward so the
|
|
640
|
+
// cursor area is visible above the keyboard. No PTY resize is sent.
|
|
641
|
+
if (isTouchDevice && window.visualViewport) {
|
|
642
|
+
const vv = window.visualViewport;
|
|
643
|
+
// Track the largest observed viewport height as "full" (no keyboard).
|
|
644
|
+
let fullHeight = vv.height;
|
|
645
|
+
let keyboardCompensating = false;
|
|
646
|
+
|
|
647
|
+
function adjustForKeyboard() {
|
|
648
|
+
// Update full height when viewport grows (keyboard closed, rotation, etc.)
|
|
649
|
+
if (vv.height > fullHeight) fullHeight = vv.height;
|
|
650
|
+
|
|
651
|
+
const keyboardHeight = fullHeight - vv.height;
|
|
652
|
+
const isKeyboardOpen = keyboardHeight > 100;
|
|
653
|
+
|
|
654
|
+
if (isKeyboardOpen && nativeInputMode) {
|
|
655
|
+
keyboardCompensating = true;
|
|
656
|
+
// Shift terminal container up so cursor area (at bottom) is visible
|
|
657
|
+
// above the keyboard. The top portion scrolls off-screen.
|
|
658
|
+
terminalContainer.style.transform = 'translateY(-' + keyboardHeight + 'px)';
|
|
659
|
+
term.scrollToBottom();
|
|
660
|
+
} else if (keyboardCompensating && !isKeyboardOpen) {
|
|
661
|
+
keyboardCompensating = false;
|
|
662
|
+
terminalContainer.style.transform = '';
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
vv.addEventListener('resize', adjustForKeyboard);
|
|
667
|
+
// iOS Safari sometimes fires scroll instead of resize during keyboard animation
|
|
668
|
+
vv.addEventListener('scroll', adjustForKeyboard);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Native input mode: forward xterm.js keyboard data directly to PTY
|
|
672
|
+
term.onData((data) => {
|
|
673
|
+
if (nativeInputMode && ws && ws.readyState === 1) {
|
|
674
|
+
ws.send(JSON.stringify({ type: 'input', data: data }));
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// Only let xterm.js process keyboard input when in native mode
|
|
679
|
+
term.attachCustomKeyEventHandler(() => nativeInputMode);
|
|
680
|
+
|
|
681
|
+
function activateNativeInput() {
|
|
682
|
+
if (nativeInputMode) return;
|
|
683
|
+
nativeInputMode = true;
|
|
684
|
+
term.focus();
|
|
685
|
+
document.getElementById('input-area').classList.add('native-mode');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function activateInputBoxMode() {
|
|
689
|
+
if (!nativeInputMode) return;
|
|
690
|
+
nativeInputMode = false;
|
|
691
|
+
term.blur();
|
|
692
|
+
document.getElementById('input-area').classList.remove('native-mode');
|
|
693
|
+
// Restore terminal layout in case keyboard was compensating
|
|
694
|
+
terminalContainer.style.transform = '';
|
|
695
|
+
updateTerminalBottom();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Auto-deactivate native mode when terminal loses focus
|
|
699
|
+
// Attach handlers to term.textarea via polling (more reliable than fixed timeout)
|
|
700
|
+
(function waitForTextarea() {
|
|
701
|
+
if (!term.textarea) {
|
|
702
|
+
setTimeout(waitForTextarea, 100);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
term.textarea.addEventListener('blur', () => {
|
|
706
|
+
setTimeout(() => {
|
|
707
|
+
if (nativeInputMode && document.activeElement !== term.textarea) {
|
|
708
|
+
activateInputBoxMode();
|
|
709
|
+
}
|
|
710
|
+
}, 100);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// Intercept image paste in native input mode (xterm.js focused)
|
|
714
|
+
term.textarea.addEventListener('paste', (e) => {
|
|
715
|
+
const items = e.clipboardData && e.clipboardData.items;
|
|
716
|
+
if (!items) return;
|
|
717
|
+
for (let i = 0; i < items.length; i++) {
|
|
718
|
+
if (items[i].type.indexOf('image/') === 0) {
|
|
719
|
+
e.preventDefault();
|
|
720
|
+
e.stopPropagation();
|
|
721
|
+
uploadImageFile(items[i].getAsFile());
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
})();
|
|
727
|
+
|
|
728
|
+
// Initial connection
|
|
729
|
+
connect();
|