Bjo53 commited on
Commit
17fd1e0
·
verified ·
1 Parent(s): 93be147

Add: free scroll, keyboard resize, modifier keys box

Browse files
Files changed (1) hide show
  1. static/index.html +169 -133
static/index.html CHANGED
@@ -5,20 +5,17 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
6
  <title>OpenCode AI Terminal</title>
7
 
8
- <!-- PWA Meta -->
9
  <meta name="theme-color" content="#27c93f"/>
10
  <meta name="apple-mobile-web-app-capable" content="yes"/>
11
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
12
  <meta name="apple-mobile-web-app-title" content="OpenCode"/>
13
  <meta name="description" content="Full screen web terminal"/>
14
 
15
- <!-- PWA Links -->
16
  <link rel="manifest" href="/static/manifest.json"/>
17
  <link rel="apple-touch-icon" href="/static/icon-192.png"/>
18
  <link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png"/>
19
  <link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png"/>
20
 
21
- <!-- Fonts & Terminal -->
22
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"/>
23
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/>
24
  <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
@@ -92,8 +89,40 @@
92
  #toolbar button:hover { background: #27c93f44; }
93
  #toolbar .sep { width: 1px; height: 20px; background: #30363d; }
94
 
95
- #terminal-container { width: 100%; height: calc(100% - 36px); display: none; }
96
- .xterm { padding: 4px; height: 100%; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  #ctx-menu {
99
  display: none; position: fixed; background: #1c2128; border: 1px solid #30363d;
@@ -141,6 +170,27 @@
141
 
142
  <div id="terminal-container"></div>
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  <div id="ctx-menu">
145
  <button id="ctx-copy">📋 Copy</button>
146
  <button id="ctx-paste">📄 Paste</button>
@@ -149,50 +199,21 @@
149
  </div>
150
 
151
  <script>
152
- // PWA Install
153
  let deferredPrompt;
154
  const installBanner = document.getElementById('install-banner');
 
 
 
 
 
155
 
156
- window.addEventListener('beforeinstallprompt', (e) => {
157
- e.preventDefault();
158
- deferredPrompt = e;
159
- installBanner.classList.add('show');
160
- });
161
-
162
- document.getElementById('install-btn').onclick = async () => {
163
- if (deferredPrompt) {
164
- deferredPrompt.prompt();
165
- const { outcome } = await deferredPrompt.userChoice;
166
- deferredPrompt = null;
167
- installBanner.classList.remove('show');
168
- }
169
- };
170
-
171
- document.getElementById('install-close').onclick = () => {
172
- installBanner.classList.remove('show');
173
- };
174
-
175
- window.addEventListener('appinstalled', () => {
176
- installBanner.classList.remove('show');
177
- deferredPrompt = null;
178
- });
179
-
180
- // Register Service Worker
181
- if ('serviceWorker' in navigator) {
182
- navigator.serviceWorker.register('/static/sw.js');
183
- }
184
-
185
- // Session persistence
186
  const SESSION_KEY = "opencode_session_id";
187
- let PASS_HASH = "";
188
- let term = null;
189
- let ws = null;
190
- let reconnectAttempts = 0;
191
- let isOnline = navigator.onLine;
192
- let lastActivity = Date.now();
193
 
194
  fetch("/api/pass").then(r => r.json()).then(d => { PASS_HASH = d.pass; });
195
-
196
  document.getElementById("pass-btn").onclick = () => unlock();
197
  document.getElementById("pass-input").addEventListener("keydown", e => { if (e.key === "Enter") unlock(); });
198
 
@@ -219,66 +240,60 @@
219
  document.getElementById("offline-banner").classList.remove("visible");
220
  if (!ws || ws.readyState !== WebSocket.OPEN) reconnect();
221
  }
222
-
223
  function setOffline() {
224
  isOnline = false;
225
  document.getElementById("offline-banner").classList.add("visible");
226
  setStatus("reconnecting", "Offline...");
227
  }
228
-
229
  window.addEventListener("online", setOnline);
230
  window.addEventListener("offline", setOffline);
231
 
232
- // Heartbeat
233
  setInterval(() => {
234
- if (ws && ws.readyState === WebSocket.OPEN) {
235
- try { ws.send(JSON.stringify({type:"ping"})); } catch(e) {}
236
- }
237
- if (Date.now() - lastActivity > 30000 && (!ws || ws.readyState !== WebSocket.OPEN)) {
238
- reconnect();
239
- }
240
  }, 10000);
241
 
 
 
 
 
 
 
 
 
 
 
242
  function initTerminal() {
243
  term = new Terminal({
244
  cursorBlink: true,
245
  fontSize: 14,
246
  fontFamily: "'JetBrains Mono', monospace",
247
- scrollback: 10000,
248
  allowProposedApi: true,
 
249
  theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#27c93f', selectionBackground: '#27c93f44' }
250
  });
251
 
252
- const fitAddon = new FitAddon.FitAddon();
253
  term.loadAddon(fitAddon);
254
  term.open(document.getElementById("terminal-container"));
255
- fitAddon.fit();
256
  term.focus();
257
 
258
  function getWsUrl() {
259
  const p = location.protocol === "https:" ? "wss" : "ws";
260
  return `${p}://${location.host}/ws`;
261
  }
262
-
263
  function getSavedSession() { return localStorage.getItem(SESSION_KEY); }
264
  function saveSession(sid) { localStorage.setItem(SESSION_KEY, sid); }
265
 
266
  function connect() {
267
  if (ws) { try { ws.close(); } catch(e) {} ws = null; }
268
-
269
  setStatus("reconnecting", reconnectAttempts > 0 ? `Reconnecting (${reconnectAttempts})...` : "Connecting...");
270
-
271
- try {
272
- ws = new WebSocket(getWsUrl());
273
- } catch(e) {
274
- setStatus("", "Failed");
275
- scheduleReconnect();
276
- return;
277
- }
278
 
279
  ws.onopen = () => {
280
- lastActivity = Date.now();
281
- reconnectAttempts = 0;
282
  setStatus("connected", "Connected");
283
  const saved = getSavedSession();
284
  ws.send(JSON.stringify({ type: "attach", session_id: saved || undefined }));
@@ -296,17 +311,11 @@
296
  } else if (msg.type === "output") {
297
  term.write(msg.data);
298
  }
299
- } catch(err) {
300
- term.write(e.data);
301
- }
302
  };
303
 
304
- ws.onerror = () => { setStatus("", "Error"); };
305
-
306
- ws.onclose = () => {
307
- setStatus("", "Disconnected");
308
- scheduleReconnect();
309
- };
310
  }
311
 
312
  function scheduleReconnect() {
@@ -314,51 +323,104 @@
314
  reconnectAttempts++;
315
  setTimeout(() => { if (isOnline) connect(); }, delay);
316
  }
317
-
318
- window.reconnect = function() {
319
- reconnectAttempts = 0;
320
- connect();
321
- };
322
 
323
  term.onData((data) => {
324
- if (ws && ws.readyState === WebSocket.OPEN) {
325
- ws.send(JSON.stringify({ type: "input", data }));
326
- }
327
  });
328
 
329
  term.onResize(() => {
330
- if (ws && ws.readyState === WebSocket.OPEN) {
331
- ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
332
- }
333
  });
334
 
335
- window.addEventListener("resize", () => { fitAddon.fit(); });
336
-
337
- // Visibility change - reconnect when tab shown
338
  document.addEventListener("visibilitychange", () => {
339
  if (!document.hidden && (!ws || ws.readyState !== WebSocket.OPEN)) reconnect();
340
  });
341
 
 
 
 
 
 
 
342
  connect();
343
  }
344
 
345
- // Toolbar buttons
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  document.getElementById("btn-copy").onclick = () => {
347
- const sel = term.getSelection();
348
  if (sel) navigator.clipboard.writeText(sel);
349
  };
350
-
351
  document.getElementById("btn-paste").onclick = async () => {
352
  try {
353
  const text = await navigator.clipboard.readText();
354
- if (text && ws && ws.readyState === WebSocket.OPEN) {
355
- ws.send(JSON.stringify({ type: "input", data: text }));
356
- }
357
  } catch(e) {}
358
  };
359
-
360
- document.getElementById("btn-selectall").onclick = () => { term.selectAll(); };
361
- document.getElementById("btn-clear").onclick = () => { term.clear(); };
362
 
363
  // Context menu
364
  const ctxMenu = document.getElementById("ctx-menu");
@@ -368,45 +430,19 @@
368
  ctxMenu.style.left = `${e.pageX}px`;
369
  ctxMenu.style.top = `${e.pageY}px`;
370
  });
 
371
 
372
- document.addEventListener("click", () => { ctxMenu.style.display = "none"; });
373
-
374
- document.getElementById("ctx-copy").onclick = () => {
375
- const sel = term.getSelection();
376
- if (sel) navigator.clipboard.writeText(sel);
377
- };
378
-
379
- document.getElementById("ctx-paste").onclick = async () => {
380
- try {
381
- const text = await navigator.clipboard.readText();
382
- if (text && ws && ws.readyState === WebSocket.OPEN) {
383
- ws.send(JSON.stringify({ type: "input", data: text }));
384
- }
385
- } catch(e) {}
386
- };
387
-
388
- document.getElementById("ctx-selectall").onclick = () => { term.selectAll(); };
389
- document.getElementById("ctx-clear").onclick = () => { term.clear(); };
390
 
391
  // Keyboard shortcuts
392
  document.addEventListener("keydown", (e) => {
393
  if (!term) return;
394
- if (e.ctrlKey && e.key === "c") {
395
- const sel = term.getSelection();
396
- if (sel) { navigator.clipboard.writeText(sel); e.preventDefault(); }
397
- }
398
- if (e.ctrlKey && e.key === "v") {
399
- e.preventDefault();
400
- navigator.clipboard.readText().then(text => {
401
- if (text && ws && ws.readyState === WebSocket.OPEN) {
402
- ws.send(JSON.stringify({ type: "input", data: text }));
403
- }
404
- });
405
- }
406
- if (e.ctrlKey && e.key === "a") {
407
- e.preventDefault();
408
- term.selectAll();
409
- }
410
  });
411
  </script>
412
  </body>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
6
  <title>OpenCode AI Terminal</title>
7
 
 
8
  <meta name="theme-color" content="#27c93f"/>
9
  <meta name="apple-mobile-web-app-capable" content="yes"/>
10
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
11
  <meta name="apple-mobile-web-app-title" content="OpenCode"/>
12
  <meta name="description" content="Full screen web terminal"/>
13
 
 
14
  <link rel="manifest" href="/static/manifest.json"/>
15
  <link rel="apple-touch-icon" href="/static/icon-192.png"/>
16
  <link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png"/>
17
  <link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png"/>
18
 
 
19
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"/>
20
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css"/>
21
  <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
 
89
  #toolbar button:hover { background: #27c93f44; }
90
  #toolbar .sep { width: 1px; height: 20px; background: #30363d; }
91
 
92
+ #terminal-container {
93
+ width: 100%; display: none; overflow: hidden;
94
+ height: calc(100vh - 36px);
95
+ }
96
+ @media (max-width: 768px) {
97
+ #terminal-container { height: var(--term-height, 50vh); }
98
+ }
99
+ .xterm { padding: 4px; height: 100%; touch-action: pan-y; }
100
+ .xterm-viewport { overflow-y: auto !important; -webkit-overflow-scrolling: touch !important; }
101
+
102
+ /* Modifier keys floating box */
103
+ #mod-box {
104
+ display: none; position: fixed; bottom: 8px; right: 8px; z-index: 9998;
105
+ background: #1c2128ee; border: 1px solid #30363d; border-radius: 10px;
106
+ padding: 6px; gap: 4px; flex-wrap: wrap; max-width: 140px;
107
+ justify-content: center; backdrop-filter: blur(8px);
108
+ }
109
+ #mod-box.active { display: flex; }
110
+ #mod-toggle {
111
+ position: fixed; bottom: 8px; right: 8px; z-index: 9997;
112
+ width: 28px; height: 28px; border-radius: 50%;
113
+ background: #1c212888; border: 1px solid #30363d55;
114
+ color: #768390; font-size: 10px; cursor: pointer;
115
+ display: flex; align-items: center; justify-content: center;
116
+ backdrop-filter: blur(4px);
117
+ }
118
+ #mod-toggle.on { background: #27c93f44; color: #27c93f; border-color: #27c93f55; }
119
+ .mod-btn {
120
+ background: #0d1117; border: 1px solid #30363d; border-radius: 5px;
121
+ color: #e6edf3; font-size: 10px; padding: 5px 8px; cursor: pointer;
122
+ font-family: 'JetBrains Mono', monospace; min-width: 32px; text-align: center;
123
+ user-select: none; -webkit-user-select: none;
124
+ }
125
+ .mod-btn:active, .mod-btn.on { background: #27c93f44; color: #27c93f; border-color: #27c93f; }
126
 
127
  #ctx-menu {
128
  display: none; position: fixed; background: #1c2128; border: 1px solid #30363d;
 
170
 
171
  <div id="terminal-container"></div>
172
 
173
+ <!-- Modifier keys toggle button (tiny, corner) -->
174
+ <button id="mod-toggle" title="Modifier keys">⌨</button>
175
+
176
+ <!-- Modifier keys panel -->
177
+ <div id="mod-box">
178
+ <button class="mod-btn" data-key="ctrl">Ctrl</button>
179
+ <button class="mod-btn" data-key="alt">Alt</button>
180
+ <button class="mod-btn" data-key="esc">Esc</button>
181
+ <button class="mod-btn" data-key="tab">Tab</button>
182
+ <button class="mod-btn" data-key="up">↑</button>
183
+ <button class="mod-btn" data-key="down">↓</button>
184
+ <button class="mod-btn" data-key="left">←</button>
185
+ <button class="mod-btn" data-key="right">→</button>
186
+ <button class="mod-btn" data-key="bs">⌫</button>
187
+ <button class="mod-btn" data-key="del">Del</button>
188
+ <button class="mod-btn" data-key="home">Home</button>
189
+ <button class="mod-btn" data-key="end">End</button>
190
+ <button class="mod-btn" data-key="pgup">PgUp</button>
191
+ <button class="mod-btn" data-key="pgdn">PgDn</button>
192
+ </div>
193
+
194
  <div id="ctx-menu">
195
  <button id="ctx-copy">📋 Copy</button>
196
  <button id="ctx-paste">📄 Paste</button>
 
199
  </div>
200
 
201
  <script>
202
+ // PWA
203
  let deferredPrompt;
204
  const installBanner = document.getElementById('install-banner');
205
+ window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; installBanner.classList.add('show'); });
206
+ document.getElementById('install-btn').onclick = async () => { if (deferredPrompt) { deferredPrompt.prompt(); deferredPrompt = null; installBanner.classList.remove('show'); } };
207
+ document.getElementById('install-close').onclick = () => installBanner.classList.remove('show');
208
+ window.addEventListener('appinstalled', () => { installBanner.classList.remove('show'); deferredPrompt = null; });
209
+ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/static/sw.js');
210
 
211
+ // Session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  const SESSION_KEY = "opencode_session_id";
213
+ let PASS_HASH = "", term = null, ws = null, reconnectAttempts = 0, isOnline = navigator.onLine, lastActivity = Date.now();
214
+ let activeMods = new Set();
 
 
 
 
215
 
216
  fetch("/api/pass").then(r => r.json()).then(d => { PASS_HASH = d.pass; });
 
217
  document.getElementById("pass-btn").onclick = () => unlock();
218
  document.getElementById("pass-input").addEventListener("keydown", e => { if (e.key === "Enter") unlock(); });
219
 
 
240
  document.getElementById("offline-banner").classList.remove("visible");
241
  if (!ws || ws.readyState !== WebSocket.OPEN) reconnect();
242
  }
 
243
  function setOffline() {
244
  isOnline = false;
245
  document.getElementById("offline-banner").classList.add("visible");
246
  setStatus("reconnecting", "Offline...");
247
  }
 
248
  window.addEventListener("online", setOnline);
249
  window.addEventListener("offline", setOffline);
250
 
 
251
  setInterval(() => {
252
+ if (ws && ws.readyState === WebSocket.OPEN) try { ws.send(JSON.stringify({type:"ping"})); } catch(e) {}
253
+ if (Date.now() - lastActivity > 30000 && (!ws || ws.readyState !== WebSocket.OPEN)) reconnect();
 
 
 
 
254
  }, 10000);
255
 
256
+ // Mobile keyboard handling
257
+ function setTermHeight() {
258
+ const vh = window.innerHeight;
259
+ const barH = document.getElementById('status-bar').offsetHeight + document.getElementById('toolbar').offsetHeight;
260
+ document.getElementById('terminal-container').style.setProperty('--term-height', (vh - barH - 40) + 'px');
261
+ document.getElementById('terminal-container').style.height = (vh - barH) + 'px';
262
+ if (fitAddon) fitAddon.fit();
263
+ }
264
+
265
+ let fitAddon = null;
266
  function initTerminal() {
267
  term = new Terminal({
268
  cursorBlink: true,
269
  fontSize: 14,
270
  fontFamily: "'JetBrains Mono', monospace",
271
+ scrollback: 50000,
272
  allowProposedApi: true,
273
+ smoothScrollDuration: 50,
274
  theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#27c93f', selectionBackground: '#27c93f44' }
275
  });
276
 
277
+ fitAddon = new FitAddon.FitAddon();
278
  term.loadAddon(fitAddon);
279
  term.open(document.getElementById("terminal-container"));
280
+ setTermHeight();
281
  term.focus();
282
 
283
  function getWsUrl() {
284
  const p = location.protocol === "https:" ? "wss" : "ws";
285
  return `${p}://${location.host}/ws`;
286
  }
 
287
  function getSavedSession() { return localStorage.getItem(SESSION_KEY); }
288
  function saveSession(sid) { localStorage.setItem(SESSION_KEY, sid); }
289
 
290
  function connect() {
291
  if (ws) { try { ws.close(); } catch(e) {} ws = null; }
 
292
  setStatus("reconnecting", reconnectAttempts > 0 ? `Reconnecting (${reconnectAttempts})...` : "Connecting...");
293
+ try { ws = new WebSocket(getWsUrl()); } catch(e) { setStatus("", "Failed"); scheduleReconnect(); return; }
 
 
 
 
 
 
 
294
 
295
  ws.onopen = () => {
296
+ lastActivity = Date.now(); reconnectAttempts = 0;
 
297
  setStatus("connected", "Connected");
298
  const saved = getSavedSession();
299
  ws.send(JSON.stringify({ type: "attach", session_id: saved || undefined }));
 
311
  } else if (msg.type === "output") {
312
  term.write(msg.data);
313
  }
314
+ } catch(err) { term.write(e.data); }
 
 
315
  };
316
 
317
+ ws.onerror = () => setStatus("", "Error");
318
+ ws.onclose = () => { setStatus("", "Disconnected"); scheduleReconnect(); };
 
 
 
 
319
  }
320
 
321
  function scheduleReconnect() {
 
323
  reconnectAttempts++;
324
  setTimeout(() => { if (isOnline) connect(); }, delay);
325
  }
326
+ window.reconnect = () => { reconnectAttempts = 0; connect(); };
 
 
 
 
327
 
328
  term.onData((data) => {
329
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data }));
 
 
330
  });
331
 
332
  term.onResize(() => {
333
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
 
 
334
  });
335
 
336
+ window.addEventListener("resize", () => { setTermHeight(); });
 
 
337
  document.addEventListener("visibilitychange", () => {
338
  if (!document.hidden && (!ws || ws.readyState !== WebSocket.OPEN)) reconnect();
339
  });
340
 
341
+ // Touch scroll for mobile
342
+ const termEl = document.getElementById("terminal-container");
343
+ termEl.addEventListener('touchstart', (e) => {
344
+ if (e.touches.length === 1) e.stopPropagation();
345
+ }, { passive: true });
346
+
347
  connect();
348
  }
349
 
350
+ // Mobile keyboard: resize when viewport changes (keyboard open/close)
351
+ if (window.visualViewport) {
352
+ window.visualViewport.addEventListener('resize', () => {
353
+ if (term) { setTermHeight(); term.focus(); }
354
+ });
355
+ }
356
+ window.addEventListener('resize', () => { if (term) setTermHeight(); });
357
+
358
+ // Modifier keys
359
+ const modToggle = document.getElementById('mod-toggle');
360
+ const modBox = document.getElementById('mod-box');
361
+
362
+ modToggle.onclick = () => {
363
+ modBox.classList.toggle('active');
364
+ modToggle.classList.toggle('on');
365
+ };
366
+
367
+ const keyMap = {
368
+ ctrl: '\x03', // Ctrl+C as default, or hold for next key
369
+ alt: '\x1b',
370
+ esc: '\x1b',
371
+ tab: '\t',
372
+ up: '\x1b[A',
373
+ down: '\x1b[B',
374
+ left: '\x1b[D',
375
+ right: '\x1b[C',
376
+ bs: '\x7f',
377
+ del: '\x1b[3~',
378
+ home: '\x1b[H',
379
+ end: '\x1b[F',
380
+ pgup: '\x1b[5~',
381
+ pgdn: '\x1b[6~'
382
+ };
383
+
384
+ document.querySelectorAll('.mod-btn').forEach(btn => {
385
+ btn.addEventListener('touchstart', (e) => {
386
+ e.preventDefault();
387
+ e.stopPropagation();
388
+ const key = btn.dataset.key;
389
+ const code = keyMap[key];
390
+ if (code && ws && ws.readyState === WebSocket.OPEN) {
391
+ ws.send(JSON.stringify({ type: "input", data: code }));
392
+ btn.classList.add('on');
393
+ setTimeout(() => btn.classList.remove('on'), 200);
394
+ term.focus();
395
+ }
396
+ });
397
+ btn.addEventListener('click', (e) => {
398
+ e.preventDefault();
399
+ e.stopPropagation();
400
+ const key = btn.dataset.key;
401
+ const code = keyMap[key];
402
+ if (code && ws && ws.readyState === WebSocket.OPEN) {
403
+ ws.send(JSON.stringify({ type: "input", data: code }));
404
+ btn.classList.add('on');
405
+ setTimeout(() => btn.classList.remove('on'), 200);
406
+ term.focus();
407
+ }
408
+ });
409
+ });
410
+
411
+ // Toolbar
412
  document.getElementById("btn-copy").onclick = () => {
413
+ const sel = term?.getSelection();
414
  if (sel) navigator.clipboard.writeText(sel);
415
  };
 
416
  document.getElementById("btn-paste").onclick = async () => {
417
  try {
418
  const text = await navigator.clipboard.readText();
419
+ if (text && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data: text }));
 
 
420
  } catch(e) {}
421
  };
422
+ document.getElementById("btn-selectall").onclick = () => term?.selectAll();
423
+ document.getElementById("btn-clear").onclick = () => term?.clear();
 
424
 
425
  // Context menu
426
  const ctxMenu = document.getElementById("ctx-menu");
 
430
  ctxMenu.style.left = `${e.pageX}px`;
431
  ctxMenu.style.top = `${e.pageY}px`;
432
  });
433
+ document.addEventListener("click", () => ctxMenu.style.display = "none");
434
 
435
+ document.getElementById("ctx-copy").onclick = () => { const s = term?.getSelection(); if (s) navigator.clipboard.writeText(s); };
436
+ document.getElementById("ctx-paste").onclick = async () => { try { const t = await navigator.clipboard.readText(); if (t && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data: t })); } catch(e) {} };
437
+ document.getElementById("ctx-selectall").onclick = () => term?.selectAll();
438
+ document.getElementById("ctx-clear").onclick = () => term?.clear();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
 
440
  // Keyboard shortcuts
441
  document.addEventListener("keydown", (e) => {
442
  if (!term) return;
443
+ if (e.ctrlKey && e.key === "c") { const s = term.getSelection(); if (s) { navigator.clipboard.writeText(s); e.preventDefault(); } }
444
+ if (e.ctrlKey && e.key === "v") { e.preventDefault(); navigator.clipboard.readText().then(t => { if (t && ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data: t })); }); }
445
+ if (e.ctrlKey && e.key === "a") { e.preventDefault(); term.selectAll(); }
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  });
447
  </script>
448
  </body>