termbeam 0.1.0 → 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.
@@ -6,6 +6,7 @@
6
6
  <meta name="apple-mobile-web-app-capable" content="yes" />
7
7
  <meta name="mobile-web-app-capable" content="yes" />
8
8
  <meta name="theme-color" content="#1e1e1e" />
9
+ <meta name="description" content="TermBeam terminal session — access your terminal remotely from any browser with a mobile-optimized touch interface." />
9
10
  <link rel="manifest" href="/manifest.json" />
10
11
  <link rel="apple-touch-icon" href="/icons/icon-192.png" />
11
12
  <title>TermBeam — Terminal</title>
@@ -309,7 +310,8 @@
309
310
  #paste-overlay {
310
311
  display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
311
312
  background: var(--overlay-bg); z-index: 150;
312
- flex-direction: column; align-items: center; justify-content: center; gap: 12px;
313
+ flex-direction: column; align-items: center; justify-content: flex-start;
314
+ padding-top: calc(80px + env(safe-area-inset-top, 0px)); gap: 12px;
313
315
  }
314
316
  #paste-overlay.visible { display: flex; }
315
317
  #paste-overlay label { font-size: 15px; color: #fff; font-weight: 600; }
@@ -319,6 +321,38 @@
319
321
  padding: 10px; font-size: 14px; font-family: 'NerdFont', 'JetBrains Mono', monospace; resize: vertical;
320
322
  }
321
323
 
324
+ #select-overlay {
325
+ display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
326
+ background: var(--bg); z-index: 160;
327
+ flex-direction: column;
328
+ }
329
+ #select-overlay.visible { display: flex; }
330
+ .select-overlay-header {
331
+ display: flex; align-items: center; justify-content: space-between;
332
+ padding: 12px 12px; border-bottom: 1px solid var(--border);
333
+ font-size: 15px; font-weight: 600; color: var(--text);
334
+ padding-top: calc(12px + env(safe-area-inset-top, 0px));
335
+ min-height: 48px; box-sizing: border-box;
336
+ }
337
+ .select-overlay-header button {
338
+ padding: 6px 12px; border: none; border-radius: 8px;
339
+ font-size: 13px; font-weight: 600; cursor: pointer;
340
+ white-space: nowrap; flex-shrink: 0;
341
+ transition: background 0.15s, transform 0.1s;
342
+ }
343
+ .select-overlay-header button:active { transform: scale(0.95); }
344
+ #select-copy { background: var(--accent); color: #fff; }
345
+ #select-close { background: var(--border); color: var(--text); }
346
+ #select-content {
347
+ flex: 1; overflow: auto; padding: 12px 16px;
348
+ font-family: 'NerdFont', 'JetBrains Mono', monospace;
349
+ font-size: 13px; line-height: 1.4; color: var(--text);
350
+ white-space: pre; word-break: normal;
351
+ -webkit-user-select: text; user-select: text;
352
+ margin: 0;
353
+ padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
354
+ }
355
+
322
356
  #reconnect-overlay {
323
357
  display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
324
358
  background: var(--overlay-bg); z-index: 100;
@@ -714,6 +748,7 @@
714
748
  <button class="key-btn" data-key="&#x1b;[H" title="Home">Home</button>
715
749
  <button class="key-btn" data-key="&#x1b;[F" title="End">End</button>
716
750
  <div class="key-sep"></div>
751
+ <button class="key-btn" id="select-btn" title="Copy text">Copy</button>
717
752
  <button class="key-btn" id="paste-btn" title="Paste from clipboard">Paste</button>
718
753
  <div class="key-sep"></div>
719
754
  <button class="key-btn wide" data-key="&#x09;" title="Autocomplete">Tab</button>
@@ -740,6 +775,18 @@
740
775
  </div>
741
776
  </div>
742
777
 
778
+ <div id="select-overlay">
779
+ <div class="select-overlay-header">
780
+ <span id="select-title">Copy Text</span>
781
+ <div style="display:flex;gap:8px">
782
+ <button id="select-copy">Copy</button>
783
+ <button id="select-close">Done</button>
784
+ </div>
785
+ </div>
786
+ <button id="select-load-more" style="display:none;width:100%;padding:8px;background:var(--surface);color:var(--accent);border:1px solid var(--border);border-radius:6px;font-size:13px;cursor:pointer;">▲ Load more</button>
787
+ <pre id="select-content"></pre>
788
+ </div>
789
+
743
790
  <!-- New Session Modal -->
744
791
  <div class="modal-overlay" id="new-session-modal">
745
792
  <div class="modal">
@@ -804,8 +851,20 @@
804
851
  const managed = new Map(); // sessionId -> ManagedSession
805
852
  let activeId = null;
806
853
  let splitMode = false;
854
+
807
855
  let splitSecondId = null;
808
856
 
857
+ // Clipboard copy fallback for non-secure contexts (HTTP over LAN)
858
+ function copyFallback(text) {
859
+ const ta = document.createElement('textarea');
860
+ ta.value = text;
861
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
862
+ document.body.appendChild(ta);
863
+ ta.select();
864
+ try { document.execCommand('copy'); showToast('Copied!'); } catch {}
865
+ document.body.removeChild(ta);
866
+ }
867
+
809
868
  // ===== DOM Refs =====
810
869
  const statusDot = document.getElementById('status-dot');
811
870
  const statusText = document.getElementById('status-text');
@@ -937,7 +996,10 @@
937
996
  renderTabs();
938
997
  setupKeyBar();
939
998
  setupPaste();
999
+ setupImagePaste();
1000
+ setupSelectMode();
940
1001
  setupNewSessionModal();
1002
+ loadShellsForModal();
941
1003
  startPolling();
942
1004
 
943
1005
  // Zoom
@@ -1120,8 +1182,14 @@
1120
1182
  // Copy on selection
1121
1183
  term.onSelectionChange(() => {
1122
1184
  const sel = term.getSelection();
1123
- if (sel && navigator.clipboard && navigator.clipboard.writeText) {
1124
- navigator.clipboard.writeText(sel).then(() => showToast('Copied!')).catch(() => {});
1185
+ if (!sel) return;
1186
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1187
+ navigator.clipboard.writeText(sel).then(() => showToast('Copied!')).catch(() => {
1188
+ // Fallback for non-secure contexts (HTTP over LAN)
1189
+ copyFallback(sel);
1190
+ });
1191
+ } else {
1192
+ copyFallback(sel);
1125
1193
  }
1126
1194
  });
1127
1195
 
@@ -1203,6 +1271,11 @@
1203
1271
  reconnectOverlay.classList.add('visible');
1204
1272
  }
1205
1273
  } else if (msg.type === 'error') {
1274
+ if (msg.message === 'Session not found') {
1275
+ ms.exited = true;
1276
+ if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
1277
+ renderTabs();
1278
+ }
1206
1279
  if (ms.id === activeId) {
1207
1280
  statusText.textContent = msg.message;
1208
1281
  reconnectOverlay.querySelector('.msg').textContent = msg.message;
@@ -1277,12 +1350,16 @@
1277
1350
  }
1278
1351
 
1279
1352
  async function removeSession(id) {
1280
- try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
1281
-
1282
1353
  const ms = managed.get(id);
1283
1354
  if (ms) {
1355
+ ms.exited = true;
1356
+ if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
1284
1357
  if (ms.ws) try { ms.ws.close(); } catch {}
1285
- if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
1358
+ }
1359
+
1360
+ try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
1361
+
1362
+ if (ms) {
1286
1363
  ms.term.dispose();
1287
1364
  ms.container.remove();
1288
1365
  managed.delete(id);
@@ -1655,18 +1732,25 @@
1655
1732
  }, 400);
1656
1733
  }
1657
1734
 
1735
+ let keyBarTouched = false;
1658
1736
  keyBar.addEventListener('mousedown', e => {
1737
+ if (keyBarTouched) { keyBarTouched = false; return; }
1659
1738
  const btn = e.target.closest('.key-btn');
1660
- if (btn) { e.preventDefault(); startRepeat(btn); }
1739
+ if (btn && btn.dataset.key) { e.preventDefault(); startRepeat(btn); }
1661
1740
  });
1662
1741
  keyBar.addEventListener('mouseup', stopRepeat);
1663
1742
  keyBar.addEventListener('mouseleave', stopRepeat);
1664
1743
 
1665
1744
  keyBar.addEventListener('touchstart', e => {
1745
+ keyBarTouched = true;
1746
+ const btn = e.target.closest('.key-btn');
1747
+ if (btn && btn.dataset.key) { e.preventDefault(); startRepeat(btn); }
1748
+ }, { passive: false });
1749
+ keyBar.addEventListener('touchend', (e) => {
1666
1750
  const btn = e.target.closest('.key-btn');
1667
- if (btn) startRepeat(btn);
1668
- }, { passive: true });
1669
- keyBar.addEventListener('touchend', stopRepeat);
1751
+ if (btn && btn.dataset.key) e.preventDefault();
1752
+ stopRepeat();
1753
+ });
1670
1754
  keyBar.addEventListener('touchcancel', stopRepeat);
1671
1755
 
1672
1756
  keyBar.addEventListener('click', e => {
@@ -1679,6 +1763,7 @@
1679
1763
  function setupPaste() {
1680
1764
  const pasteOverlay = document.getElementById('paste-overlay');
1681
1765
  const pasteInput = document.getElementById('paste-input');
1766
+ const pasteBtn = document.getElementById('paste-btn');
1682
1767
 
1683
1768
  function openPasteModal() {
1684
1769
  pasteInput.value = '';
@@ -1686,19 +1771,65 @@
1686
1771
  pasteInput.focus();
1687
1772
  }
1688
1773
 
1689
- document.getElementById('paste-btn').addEventListener('mousedown', e => e.preventDefault());
1690
- document.getElementById('paste-btn').addEventListener('click', () => {
1774
+ async function handlePaste() {
1775
+ // Try clipboard API for text first (most common), then images, then fallback to modal
1691
1776
  if (navigator.clipboard && navigator.clipboard.readText) {
1692
- navigator.clipboard.readText().then(text => {
1693
- const ms = managed.get(activeId);
1694
- if (text && ms && ms.ws && ms.ws.readyState === 1) {
1695
- ms.ws.send(JSON.stringify({ type: 'input', data: text }));
1696
- showToast('Pasted!');
1777
+ try {
1778
+ const text = await navigator.clipboard.readText();
1779
+ if (text) {
1780
+ const ms = managed.get(activeId);
1781
+ if (ms && ms.ws && ms.ws.readyState === 1) {
1782
+ ms.ws.send(JSON.stringify({ type: 'input', data: text }));
1783
+ showToast('Pasted!');
1784
+ }
1785
+ return;
1697
1786
  }
1698
- }).catch(() => openPasteModal());
1699
- } else {
1700
- openPasteModal();
1787
+ } catch (err) {
1788
+ console.warn('clipboard.readText failed:', err.message);
1789
+ }
1701
1790
  }
1791
+ // Image paste: try clipboard.read() only if readText didn't work
1792
+ if (navigator.clipboard && navigator.clipboard.read) {
1793
+ try {
1794
+ const items = await navigator.clipboard.read();
1795
+ for (const item of items) {
1796
+ const imageType = item.types.find(t => t.startsWith('image/'));
1797
+ if (imageType) {
1798
+ const blob = await item.getType(imageType);
1799
+ const res = await fetch('/api/upload', {
1800
+ method: 'POST',
1801
+ headers: { 'Content-Type': imageType },
1802
+ body: blob,
1803
+ credentials: 'same-origin',
1804
+ });
1805
+ if (!res.ok) throw new Error('Upload failed');
1806
+ const data = await res.json();
1807
+ const ms = managed.get(activeId);
1808
+ if (ms && ms.ws && ms.ws.readyState === 1) {
1809
+ ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
1810
+ showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
1811
+ }
1812
+ return;
1813
+ }
1814
+ }
1815
+ } catch (err) {
1816
+ console.warn('clipboard.read failed:', err.message);
1817
+ }
1818
+ }
1819
+ openPasteModal();
1820
+ }
1821
+
1822
+ // Use touchend directly for iOS Safari (click may not fire reliably)
1823
+ let pasteTouched = false;
1824
+ pasteBtn.addEventListener('touchend', (e) => {
1825
+ e.preventDefault();
1826
+ pasteTouched = true;
1827
+ handlePaste();
1828
+ });
1829
+ pasteBtn.addEventListener('mousedown', e => e.preventDefault());
1830
+ pasteBtn.addEventListener('click', () => {
1831
+ if (pasteTouched) { pasteTouched = false; return; }
1832
+ handlePaste();
1702
1833
  });
1703
1834
 
1704
1835
  document.getElementById('paste-send').addEventListener('click', () => {
@@ -1717,6 +1848,130 @@
1717
1848
  });
1718
1849
  }
1719
1850
 
1851
+ // ===== Select Text Overlay =====
1852
+ function setupSelectMode() {
1853
+ const selectBtn = document.getElementById('select-btn');
1854
+ const selectOverlay = document.getElementById('select-overlay');
1855
+ const selectContent = document.getElementById('select-content');
1856
+ const PAGE_SIZE = 200;
1857
+ let allLines = [];
1858
+ let loadedFrom = 0;
1859
+
1860
+ function renderLines(from, to) {
1861
+ return allLines.slice(from, to).join('\n');
1862
+ }
1863
+
1864
+ function openSelectOverlay() {
1865
+ const ms = managed.get(activeId);
1866
+ if (!ms) return;
1867
+ const buf = ms.term.buffer.active;
1868
+ allLines = [];
1869
+ for (let i = 0; i < buf.length; i++) {
1870
+ const line = buf.getLine(i);
1871
+ if (line) allLines.push(line.translateToString(true));
1872
+ }
1873
+ // Trim trailing empty lines
1874
+ while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') allLines.pop();
1875
+
1876
+ // Show last PAGE_SIZE lines
1877
+ loadedFrom = Math.max(0, allLines.length - PAGE_SIZE);
1878
+ selectContent.textContent = renderLines(loadedFrom, allLines.length);
1879
+
1880
+ // Show/hide "Load more" button
1881
+ const loadMoreBtn = document.getElementById('select-load-more');
1882
+ loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
1883
+ loadMoreBtn.textContent = `▲ Load more (${loadedFrom} lines above)`;
1884
+
1885
+ // Show line count in title
1886
+ const title = document.getElementById('select-title');
1887
+ const shown = allLines.length - loadedFrom;
1888
+ title.textContent = allLines.length <= PAGE_SIZE
1889
+ ? `Copy Text (${allLines.length} lines)`
1890
+ : `Copy Text (${shown}/${allLines.length} lines)`;
1891
+
1892
+ selectBtn.style.display = 'none';
1893
+ selectOverlay.classList.add('visible');
1894
+ selectContent.scrollTop = selectContent.scrollHeight;
1895
+ }
1896
+
1897
+ document.getElementById('select-load-more').addEventListener('click', () => {
1898
+ const prevHeight = selectContent.scrollHeight;
1899
+ const newFrom = Math.max(0, loadedFrom - PAGE_SIZE);
1900
+ const chunk = renderLines(newFrom, loadedFrom);
1901
+ selectContent.textContent = chunk + '\n' + selectContent.textContent;
1902
+ loadedFrom = newFrom;
1903
+ // Keep scroll position stable
1904
+ selectContent.scrollTop = selectContent.scrollHeight - prevHeight;
1905
+ const loadMoreBtn = document.getElementById('select-load-more');
1906
+ loadMoreBtn.style.display = loadedFrom > 0 ? 'block' : 'none';
1907
+ loadMoreBtn.textContent = loadedFrom > 0 ? `▲ Load more (${loadedFrom} lines above)` : '';
1908
+ // Update title
1909
+ const title = document.getElementById('select-title');
1910
+ const shown = allLines.length - loadedFrom;
1911
+ title.textContent = `Copy Text (${shown}/${allLines.length} lines)`;
1912
+ });
1913
+
1914
+ selectBtn.addEventListener('mousedown', e => e.preventDefault());
1915
+ selectBtn.addEventListener('click', openSelectOverlay);
1916
+
1917
+ document.getElementById('select-copy').addEventListener('click', () => {
1918
+ // Copy finger selection if any, otherwise copy all loaded text
1919
+ const sel = window.getSelection();
1920
+ const text = (sel && sel.toString()) ? sel.toString() : selectContent.textContent;
1921
+ if (!text) return;
1922
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1923
+ navigator.clipboard.writeText(text).then(() => showToast('Copied!')).catch(() => {
1924
+ copyFallback(text);
1925
+ });
1926
+ } else {
1927
+ copyFallback(text);
1928
+ }
1929
+ });
1930
+
1931
+ document.getElementById('select-close').addEventListener('click', () => {
1932
+ selectOverlay.classList.remove('visible');
1933
+ selectContent.textContent = '';
1934
+ allLines = [];
1935
+ selectBtn.style.display = '';
1936
+ const ms = managed.get(activeId);
1937
+ if (ms) ms.term.focus();
1938
+ });
1939
+ }
1940
+
1941
+ // ===== Image Paste =====
1942
+ function setupImagePaste() {
1943
+ document.addEventListener('paste', async (e) => {
1944
+ const items = e.clipboardData && e.clipboardData.items;
1945
+ if (!items) return;
1946
+
1947
+ for (const item of items) {
1948
+ if (item.type.startsWith('image/')) {
1949
+ e.preventDefault();
1950
+ const blob = item.getAsFile();
1951
+ if (!blob) return;
1952
+
1953
+ try {
1954
+ const res = await fetch('/api/upload', {
1955
+ method: 'POST',
1956
+ headers: { 'Content-Type': item.type },
1957
+ body: blob,
1958
+ });
1959
+ if (!res.ok) throw new Error('Upload failed');
1960
+ const data = await res.json();
1961
+ const ms = managed.get(activeId);
1962
+ if (ms && ms.ws && ms.ws.readyState === 1) {
1963
+ ms.ws.send(JSON.stringify({ type: 'input', data: data.path + ' ' }));
1964
+ showToast('Image pasted: ' + data.path.split(/[/\\]/).pop());
1965
+ }
1966
+ } catch (err) {
1967
+ showToast('Image paste failed');
1968
+ }
1969
+ return;
1970
+ }
1971
+ }
1972
+ });
1973
+ }
1974
+
1720
1975
  // ===== New Session Modal =====
1721
1976
  let shellsLoaded = false;
1722
1977
 
@@ -1755,9 +2010,16 @@
1755
2010
  const nsBrowserBreadcrumb = document.getElementById('ns-browser-breadcrumb');
1756
2011
  const nsBrowserPath = document.getElementById('ns-browser-path');
1757
2012
  let nsBrowsePath = '/';
1758
-
1759
- document.getElementById('ns-browse-btn').addEventListener('click', () => {
1760
- const initial = nsCwdInput.value.trim() || '/';
2013
+ let serverCwd = '/';
2014
+
2015
+ document.getElementById('ns-browse-btn').addEventListener('click', async () => {
2016
+ if (serverCwd === '/') {
2017
+ try {
2018
+ const data = await fetch('/api/shells').then(r => r.json());
2019
+ if (data.cwd) serverCwd = data.cwd;
2020
+ } catch {}
2021
+ }
2022
+ const initial = nsCwdInput.value.trim() || serverCwd;
1761
2023
  nsBrowseNavigate(initial);
1762
2024
  nsBrowserOverlay.classList.add('visible');
1763
2025
  });
@@ -1782,11 +2044,17 @@
1782
2044
  try {
1783
2045
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
1784
2046
  const data = await res.json();
1785
- if (!data.dirs.length) {
1786
- nsBrowserList.innerHTML = '<div class="browser-empty">No subfolders</div>';
1787
- return;
2047
+ let items = '';
2048
+ // Add parent (..) entry unless at root
2049
+ const parent = dir.replace(/[/\\][^/\\]+$/, '') || (dir.includes('\\') ? dir.match(/^[A-Za-z]:\\/)?.[0] : '/');
2050
+ if (parent && parent !== dir) {
2051
+ items += `<div class="folder-item" data-path="${escAttr(parent)}">
2052
+ <span class="folder-icon">📁</span>
2053
+ <span class="folder-name">..</span>
2054
+ <span class="folder-arrow">›</span>
2055
+ </div>`;
1788
2056
  }
1789
- nsBrowserList.innerHTML = data.dirs.map(d => {
2057
+ items += data.dirs.map(d => {
1790
2058
  const name = d.split(/[/\\]/).pop();
1791
2059
  return `<div class="folder-item" data-path="${escAttr(d)}">
1792
2060
  <span class="folder-icon">📁</span>
@@ -1794,6 +2062,7 @@
1794
2062
  <span class="folder-arrow">›</span>
1795
2063
  </div>`;
1796
2064
  }).join('');
2065
+ nsBrowserList.innerHTML = items || '<div class="browser-empty">No subfolders</div>';
1797
2066
  nsBrowserList.querySelectorAll('.folder-item').forEach(el => {
1798
2067
  el.addEventListener('click', () => nsBrowseNavigate(el.dataset.path));
1799
2068
  });
@@ -1804,13 +2073,15 @@
1804
2073
  }
1805
2074
 
1806
2075
  function nsBrowseRenderBreadcrumb(dir) {
1807
- const parts = dir.split('/').filter(Boolean);
1808
- let html = `<button class="crumb" data-path="/">/</button>`;
1809
- let accumulated = '';
2076
+ const sep = dir.includes('\\') ? '\\' : '/';
2077
+ const parts = dir.split(/[/\\]/).filter(Boolean);
2078
+ const isWindows = /^[A-Za-z]:/.test(dir);
2079
+ let html = isWindows ? '' : `<button class="crumb" data-path="/">/</button>`;
2080
+ let accumulated = isWindows ? '' : '';
1810
2081
  parts.forEach((part, i) => {
1811
- accumulated += '/' + part;
2082
+ accumulated += (i === 0 && isWindows ? '' : sep) + part;
1812
2083
  const isCurrent = i === parts.length - 1;
1813
- html += `<span class="crumb-sep">›</span>`;
2084
+ if (i > 0 || isWindows) html += `<span class="crumb-sep">›</span>`;
1814
2085
  html += `<button class="crumb${isCurrent ? ' current' : ''}" data-path="${escAttr(accumulated)}">${esc(part)}</button>`;
1815
2086
  });
1816
2087
  nsBrowserBreadcrumb.innerHTML = html;
@@ -1826,6 +2097,10 @@
1826
2097
  const sel = document.getElementById('ns-shell');
1827
2098
  try {
1828
2099
  const data = await fetch('/api/shells').then(r => r.json());
2100
+ if (data.cwd) {
2101
+ serverCwd = data.cwd;
2102
+ document.getElementById('ns-cwd').placeholder = data.cwd;
2103
+ }
1829
2104
  sel.innerHTML = data.shells.map(s =>
1830
2105
  '<option value="' + escAttr(s.cmd) + '"' + (s.cmd === data.default ? ' selected' : '') + '>'
1831
2106
  + esc(s.name) + ' (' + esc(s.cmd) + ')</option>'
@@ -1898,8 +2173,9 @@
1898
2173
  for (const id of [...managed.keys()]) {
1899
2174
  if (!serverIds.has(id)) {
1900
2175
  const ms = managed.get(id);
2176
+ ms.exited = true;
2177
+ if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
1901
2178
  if (ms.ws) try { ms.ws.close(); } catch {}
1902
- if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
1903
2179
  ms.term.dispose();
1904
2180
  ms.container.remove();
1905
2181
  managed.delete(id);
@@ -1913,6 +2189,7 @@
1913
2189
  statusText.textContent = '';
1914
2190
  statusDot.className = '';
1915
2191
  document.getElementById('stop-btn').style.display = 'none';
2192
+ reconnectOverlay.classList.remove('visible');
1916
2193
  }
1917
2194
  }
1918
2195
  }
package/src/auth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const crypto = require('crypto');
2
+ const log = require('./logger');
2
3
 
3
4
  const LOGIN_HTML = `<!DOCTYPE html>
4
5
  <html lang="en">
@@ -107,6 +108,7 @@ function createAuth(password) {
107
108
  const attempts = authAttempts.get(ip) || [];
108
109
  const recent = attempts.filter((t) => now - t < window);
109
110
  if (recent.length >= maxAttempts) {
111
+ log.warn(`Auth: rate limit exceeded for ${ip}`);
110
112
  return res.status(429).json({ error: 'Too many attempts. Try again later.' });
111
113
  }
112
114
  recent.push(now);