webtun 1.4.2 → 1.4.5

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/public/index.html CHANGED
@@ -14,6 +14,8 @@
14
14
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
15
15
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
16
16
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
17
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.7.0/lib/addon-unicode11.min.js"></script>
18
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.1/lib/addon-webgl.min.js"></script>
17
19
  <script src="https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js"></script>
18
20
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
19
21
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/lib/codemirror.min.css">
@@ -166,6 +168,8 @@ svg { display: block; }
166
168
  .term-wrapper.active { display: flex; }
167
169
  .xterm { flex: 1; }
168
170
  .xterm-viewport { border-radius: 0; }
171
+ .term-bell-flash { animation: bellFlash 0.15s ease-out; }
172
+ @keyframes bellFlash { 0% { filter: brightness(1.8); } 100% { filter: brightness(1); } }
169
173
  #terminals.tiles-mode { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); grid-auto-rows: 1fr; gap: 4px; padding: 4px; position: relative; overflow: auto; min-height: 0; overscroll-behavior: none; }
170
174
  #terminals.tiles-mode .term-wrapper { position: relative; inset: unset; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; min-height: 150px; }
171
175
  #terminals.tiles-mode .term-wrapper.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
@@ -1152,6 +1156,7 @@ svg { display: block; }
1152
1156
  <div style="display:flex;align-items:center;gap:8px">
1153
1157
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
1154
1158
  <span>WebTun</span>
1159
+ <span id="about-version" style="color:var(--fg3);font-size:11px"></span>
1155
1160
  </div>
1156
1161
  <div style="display:flex;align-items:center;gap:8px">
1157
1162
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--fg2)" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
@@ -1192,6 +1197,15 @@ svg { display: block; }
1192
1197
  <div class="ctx-item danger" id="ctx-delete" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> Delete</div>
1193
1198
  </div>
1194
1199
 
1200
+ <!-- Terminal context menu -->
1201
+ <div id="term-ctx-menu" role="menu" style="position:fixed;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 4px 20px var(--shadow-color);z-index:500;display:none;min-width:160px;overflow:hidden">
1202
+ <div class="ctx-item" id="term-ctx-copy" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy</div>
1203
+ <div class="ctx-item" id="term-ctx-paste" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="14" y="2" width="8" height="8" rx="2"/><path d="M16 14H8M8 18h8"/><path d="M5 4h4"/><rect x="2" y="6" width="16" height="16" rx="2"/></svg> Paste</div>
1204
+ <div class="ctx-item" id="term-ctx-select-all" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/><path d="M3 9h18"/><path d="M3 15h18"/></svg> Select All</div>
1205
+ <div class="ctx-sep"></div>
1206
+ <div class="ctx-item" id="term-ctx-clear" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/></svg> Clear</div>
1207
+ </div>
1208
+
1195
1209
  <!-- Fuzzy Finder Modal -->
1196
1210
  <div class="overlay" id="finder-overlay">
1197
1211
  <div class="modal modal-md" style="padding:12px">
@@ -1361,6 +1375,7 @@ let settings = {
1361
1375
  cursor: 'block', blink: true, scrollback: 5000, bell: false,
1362
1376
  mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false
1363
1377
  };
1378
+ let serverPlatform = '';
1364
1379
 
1365
1380
  const isElectron = !!(window.electronAPI && window.electronAPI.isElectron);
1366
1381
 
@@ -1470,6 +1485,7 @@ async function unlockApp() {
1470
1485
  const info = await api('/api/home');
1471
1486
  homeDir = info.home;
1472
1487
  currentPath = homeDir;
1488
+ serverPlatform = info.platform || '';
1473
1489
  document.getElementById('hostname-badge').textContent = info.hostname || '—';
1474
1490
 
1475
1491
  // Check tmux availability
@@ -1818,10 +1834,77 @@ function connectWebSocket(tab, isReconnect = false) {
1818
1834
  setupVisualViewport();
1819
1835
  };
1820
1836
 
1837
+ // OSC sequence handler: intercepts OSC 7, 133, 52 before passing to xterm.js
1838
+ let oscBuf = '';
1839
+ function processTerminalOutput(data) {
1840
+ const str = typeof data === 'string' ? data : new TextDecoder().decode(data);
1841
+ let out = '';
1842
+ let i = 0;
1843
+ while (i < str.length) {
1844
+ // Look for ESC ] (OSC introducer)
1845
+ if (str[i] === '\x1b' && str[i + 1] === ']') {
1846
+ const endIdx = str.indexOf('\x07', i + 2);
1847
+ const stIdx = str.indexOf('\x1b\\', i + 2);
1848
+ let oscEnd = -1;
1849
+ if (endIdx !== -1 && (stIdx === -1 || endIdx < stIdx)) oscEnd = endIdx;
1850
+ else if (stIdx !== -1) oscEnd = stIdx;
1851
+
1852
+ if (oscEnd !== -1) {
1853
+ const oscData = str.substring(i + 2, oscEnd);
1854
+ const semi = oscData.indexOf(';');
1855
+ if (semi !== -1) {
1856
+ const code = oscData.substring(0, semi);
1857
+ const value = oscData.substring(semi + 1);
1858
+ if (code === '7') {
1859
+ // OSC 7: CWD update — file://hostname/path
1860
+ try {
1861
+ const url = new URL(value);
1862
+ tab.cwd = decodeURIComponent(url.pathname);
1863
+ } catch (_) {}
1864
+ } else if (code === '133') {
1865
+ // OSC 133: Shell integration markers (prompt/cmd start/end/done)
1866
+ // Handled silently — available for future command tracking
1867
+ } else if (code === '52') {
1868
+ // OSC 52: Clipboard operations
1869
+ const parts = value.split(';');
1870
+ const targets = parts[0] || 'c';
1871
+ const b64 = parts.slice(1).join(';');
1872
+ if (b64) {
1873
+ // SET clipboard
1874
+ try {
1875
+ const decoded = atob(b64);
1876
+ if (targets.includes('c') || targets.includes('p')) {
1877
+ navigator.clipboard.writeText(decoded).catch(() => {});
1878
+ }
1879
+ } catch (_) {}
1880
+ } else {
1881
+ // GET clipboard — respond with current clipboard contents
1882
+ navigator.clipboard.readText().then(text => {
1883
+ const response = '\x1b]52;c;' + btoa(text) + '\x07';
1884
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN) {
1885
+ const enc = new TextEncoder().encode(response);
1886
+ const buf = new Uint8Array(1 + enc.length);
1887
+ buf[0] = 0x00; buf.set(enc, 1);
1888
+ tab.ws.send(buf.buffer);
1889
+ }
1890
+ }).catch(() => {});
1891
+ }
1892
+ }
1893
+ }
1894
+ i = oscEnd + (str[oscEnd] === '\x07' ? 1 : 2);
1895
+ continue;
1896
+ }
1897
+ }
1898
+ out += str[i];
1899
+ i++;
1900
+ }
1901
+ if (out) tab.term.write(out);
1902
+ }
1903
+
1821
1904
  ws.onmessage = e => {
1822
1905
  const buf = new Uint8Array(e.data);
1823
1906
  const type = buf[0], payload = buf.slice(1);
1824
- if (type === 0x00) tab.term.write(payload);
1907
+ if (type === 0x00) processTerminalOutput(payload);
1825
1908
  else if (type === 0x01) tab.term.writeln('\r\n\x1b[31m[Process exited]\x1b[0m');
1826
1909
  else if (type === 0x02) tab.term.writeln('\r\n\x1b[31m' + new TextDecoder().decode(payload) + '\x1b[0m');
1827
1910
  };
@@ -1918,7 +2001,7 @@ function initTerminal(tab) {
1918
2001
  scrollback: settings.scrollback,
1919
2002
  allowTransparency: false,
1920
2003
  theme: getXtermTheme(),
1921
- windowsMode: false,
2004
+ windowsMode: serverPlatform === 'win32',
1922
2005
  convertEol: false,
1923
2006
  bellStyle: settings.bell ? 'sound' : 'none',
1924
2007
  smoothScrollDuration: 80,
@@ -1938,6 +2021,19 @@ function initTerminal(tab) {
1938
2021
  term.loadAddon(searchAddon);
1939
2022
  term.loadAddon(webLinksAddon);
1940
2023
 
2024
+ try {
2025
+ const unicodeAddon = new Unicode11Addon.Unicode11Addon();
2026
+ term.loadAddon(unicodeAddon);
2027
+ term.unicode.activeVersion = '11';
2028
+ } catch (_) {}
2029
+
2030
+ // GPU-accelerated renderer — falls back to canvas if WebGL unavailable
2031
+ try {
2032
+ const webglAddon = new WebglAddon.WebglAddon();
2033
+ webglAddon.onContextLoss(() => { webglAddon.dispose(); });
2034
+ term.loadAddon(webglAddon);
2035
+ } catch (_) {}
2036
+
1941
2037
  term.open(tab.wrapper);
1942
2038
 
1943
2039
  tab.term = term;
@@ -1959,6 +2055,23 @@ function initTerminal(tab) {
1959
2055
  tab.el.querySelector('.tab-title').textContent = tab.title;
1960
2056
  });
1961
2057
 
2058
+ // Bell: visual flash + desktop notification for background tabs
2059
+ term.onBell(() => {
2060
+ // Visual flash on terminal screen
2061
+ const screen = term.element?.querySelector('.xterm-screen');
2062
+ if (screen) {
2063
+ screen.classList.remove('term-bell-flash');
2064
+ void screen.offsetWidth; // reflow to restart animation
2065
+ screen.classList.add('term-bell-flash');
2066
+ }
2067
+ // Desktop notification if tab is in the background
2068
+ if (document.hidden && settings.bell && Notification.permission === 'granted') {
2069
+ try {
2070
+ new Notification(tab.title || 'WebTun', { body: 'Terminal bell', tag: 'webtun-bell' });
2071
+ } catch (_) {}
2072
+ }
2073
+ });
2074
+
1962
2075
  // Resize observer
1963
2076
  tab.resizeObserver = new ResizeObserver(() => {
1964
2077
  if (activeTabId === tab.id) { try { fitTerm(tab); } catch(e) { console.warn(e); } }
@@ -1978,6 +2091,55 @@ function initTerminal(tab) {
1978
2091
  }
1979
2092
  });
1980
2093
 
2094
+ // Terminal right-click context menu
2095
+ term.element.addEventListener('contextmenu', e => {
2096
+ e.preventDefault();
2097
+ const menu = document.getElementById('term-ctx-menu');
2098
+ const sel = term.getSelection();
2099
+ document.getElementById('term-ctx-copy').style.opacity = sel ? '' : '0.5';
2100
+ menu.style.left = Math.min(e.clientX, window.innerWidth - 170) + 'px';
2101
+ menu.style.top = Math.min(e.clientY, window.innerHeight - 160) + 'px';
2102
+ menu.style.display = 'block';
2103
+ });
2104
+
2105
+ // Touch-to-mouse translation for TUI apps (mobile)
2106
+ let _tapTimeout = null, _tapPos = null, _longPressTimer = null;
2107
+ term.element.addEventListener('touchstart', e => {
2108
+ if (e.touches.length !== 1) return;
2109
+ const touch = e.touches[0];
2110
+ _tapPos = { x: touch.clientX, y: touch.clientY, time: Date.now() };
2111
+ // Long press detection for right-click
2112
+ _longPressTimer = setTimeout(() => {
2113
+ if (_tapPos) {
2114
+ const evt = new MouseEvent('mousedown', { clientX: _tapPos.x, clientY: _tapPos.y, button: 2, bubbles: true });
2115
+ term.element.dispatchEvent(evt);
2116
+ const upEvt = new MouseEvent('mouseup', { clientX: _tapPos.x, clientY: _tapPos.y, button: 2, bubbles: true });
2117
+ term.element.dispatchEvent(upEvt);
2118
+ _tapPos = null;
2119
+ }
2120
+ }, 500);
2121
+ }, { passive: true });
2122
+ term.element.addEventListener('touchend', e => {
2123
+ clearTimeout(_longPressTimer);
2124
+ if (!_tapPos || e.changedTouches.length !== 1) return;
2125
+ const touch = e.changedTouches[0];
2126
+ const dx = Math.abs(touch.clientX - _tapPos.x);
2127
+ const dy = Math.abs(touch.clientY - _tapPos.y);
2128
+ const dt = Date.now() - _tapPos.time;
2129
+ if (dx < 10 && dy < 10 && dt < 300) {
2130
+ // Short tap → left click
2131
+ const evt = new MouseEvent('mousedown', { clientX: touch.clientX, clientY: touch.clientY, button: 0, bubbles: true });
2132
+ term.element.dispatchEvent(evt);
2133
+ const upEvt = new MouseEvent('mouseup', { clientX: touch.clientX, clientY: touch.clientY, button: 0, bubbles: true });
2134
+ term.element.dispatchEvent(upEvt);
2135
+ }
2136
+ _tapPos = null;
2137
+ }, { passive: true });
2138
+ term.element.addEventListener('touchmove', () => {
2139
+ clearTimeout(_longPressTimer);
2140
+ _tapPos = null;
2141
+ }, { passive: true });
2142
+
1981
2143
  // Fit terminal then connect WebSocket — ensures WS uses correct dimensions
1982
2144
  requestAnimationFrame(() => {
1983
2145
  fitTerm(tab);
@@ -2109,6 +2271,10 @@ function sendKey(key) {
2109
2271
  altLatch = false;
2110
2272
  }
2111
2273
  updateModifierButtons();
2274
+ } else {
2275
+ shiftLatch = false;
2276
+ altLatch = false;
2277
+ updateModifierButtons();
2112
2278
  }
2113
2279
  const tab = getActiveTab();
2114
2280
  if (tab?.ws?.readyState === WebSocket.OPEN) {
@@ -2437,27 +2603,74 @@ function formatSize(bytes) {
2437
2603
  return (bytes / 1073741824).toFixed(1) + ' GB';
2438
2604
  }
2439
2605
 
2440
- function renderBreadcrumb(path) {
2606
+ function renderBreadcrumb(fullPath) {
2441
2607
  const el = document.getElementById('file-breadcrumb');
2442
2608
  if (!el) return;
2443
- const parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
2609
+ const isWin = /\\/.test(fullPath) || /^[A-Za-z]:/.test(fullPath);
2610
+ const sep = isWin ? '\\' : '/';
2444
2611
  let html = '';
2445
- let accumulated = '';
2446
- for (const part of parts) {
2447
- accumulated += '/' + part;
2448
- const isLast = part === parts[parts.length - 1];
2449
- if (isLast) {
2450
- html += `<span style="color:var(--fg);font-weight:500">${escHtml(part)}</span>`;
2612
+
2613
+ if (isWin) {
2614
+ // e.g. C:\Users\name or \\server\share\path
2615
+ const normalized = fullPath.replace(/\//g, '\\');
2616
+ const unc = normalized.startsWith('\\\\');
2617
+ let rest = normalized;
2618
+ let accumulated = '';
2619
+ const segments = [];
2620
+
2621
+ if (unc) {
2622
+ const m = normalized.match(/^\\\\[^\\]+\\[^\\]+/);
2623
+ if (m) {
2624
+ segments.push({ label: m[0], path: m[0] });
2625
+ rest = normalized.slice(m[0].length).replace(/^\\+/, '');
2626
+ accumulated = m[0];
2627
+ }
2451
2628
  } else {
2452
- html += `<a href="#" data-path="${escHtml(accumulated)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(part)}</a>`;
2453
- html += `<span style="color:var(--fg2);margin:0 2px">/</span>`;
2629
+ const driveMatch = normalized.match(/^([A-Za-z]:)(.*)$/);
2630
+ if (driveMatch) {
2631
+ const root = driveMatch[1] + '\\';
2632
+ segments.push({ label: driveMatch[1], path: root });
2633
+ rest = (driveMatch[2] || '').replace(/^\\+/, '');
2634
+ accumulated = root;
2635
+ }
2636
+ }
2637
+
2638
+ const parts = rest.split('\\').filter(Boolean);
2639
+ for (let i = 0; i < parts.length; i++) {
2640
+ accumulated = accumulated.endsWith('\\') ? accumulated + parts[i] : accumulated + '\\' + parts[i];
2641
+ segments.push({ label: parts[i], path: accumulated });
2642
+ }
2643
+
2644
+ if (!segments.length) {
2645
+ html = `<span style="color:var(--fg2)">\\</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
2646
+ } else {
2647
+ html = segments.map((seg, i) => {
2648
+ const isLast = i === segments.length - 1;
2649
+ if (isLast) return `<span style="color:var(--fg);font-weight:500">${escHtml(seg.label)}</span>`;
2650
+ return `<a href="#" data-path="${escHtml(seg.path)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(seg.label)}</a>` +
2651
+ `<span style="color:var(--fg2);margin:0 2px">\\</span>`;
2652
+ }).join('');
2653
+ }
2654
+ } else {
2655
+ const parts = fullPath.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
2656
+ let accumulated = '';
2657
+ for (const part of parts) {
2658
+ accumulated += '/' + part;
2659
+ const isLast = part === parts[parts.length - 1];
2660
+ if (isLast) {
2661
+ html += `<span style="color:var(--fg);font-weight:500">${escHtml(part)}</span>`;
2662
+ } else {
2663
+ html += `<a href="#" data-path="${escHtml(accumulated)}" style="color:var(--accent);text-decoration:none;padding:4px 2px;display:inline-block">${escHtml(part)}</a>`;
2664
+ html += `<span style="color:var(--fg2);margin:0 2px">/</span>`;
2665
+ }
2666
+ }
2667
+ if (!parts.length) {
2668
+ html = `<span style="color:var(--fg2)">/</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
2454
2669
  }
2455
2670
  }
2456
- if (!parts.length) {
2457
- html = `<span style="color:var(--fg2)">/</span><span style="color:var(--fg2);font-size:11px;margin-left:6px">(root)</span>`;
2458
- }
2671
+
2459
2672
  el.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--fg2);margin-right:4px;vertical-align:middle"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' + html;
2460
-
2673
+
2461
2674
  if (!el.dataset.delegated) {
2462
2675
  el.addEventListener('click', e => {
2463
2676
  const a = e.target.closest('a[data-path]');
@@ -2472,6 +2685,27 @@ function renderBreadcrumb(path) {
2472
2685
  // ═══════════════════════════════════════════════════════
2473
2686
  let fsClipboard = null;
2474
2687
 
2688
+ // Terminal context menu actions (registered once)
2689
+ document.getElementById('term-ctx-copy').addEventListener('click', () => {
2690
+ const t = getActiveTab();
2691
+ if (t?.term) { const sel = t.term.getSelection(); if (sel) navigator.clipboard.writeText(sel); }
2692
+ document.getElementById('term-ctx-menu').style.display = 'none';
2693
+ });
2694
+ document.getElementById('term-ctx-paste').addEventListener('click', () => {
2695
+ pasteToTerminal();
2696
+ document.getElementById('term-ctx-menu').style.display = 'none';
2697
+ });
2698
+ document.getElementById('term-ctx-select-all').addEventListener('click', () => {
2699
+ const t = getActiveTab();
2700
+ if (t?.term) t.term.selectAll();
2701
+ document.getElementById('term-ctx-menu').style.display = 'none';
2702
+ });
2703
+ document.getElementById('term-ctx-clear').addEventListener('click', () => {
2704
+ const t = getActiveTab();
2705
+ if (t?.term) t.term.clear();
2706
+ document.getElementById('term-ctx-menu').style.display = 'none';
2707
+ });
2708
+
2475
2709
  function showCtxMenu(e, file) {
2476
2710
  e.preventDefault();
2477
2711
  ctxTarget = file;
@@ -2504,7 +2738,11 @@ function showCtxMenu(e, file) {
2504
2738
  });
2505
2739
  }
2506
2740
 
2507
- document.addEventListener('click', () => document.getElementById('ctx-menu').classList.remove('open'));
2741
+ document.addEventListener('click', () => {
2742
+ document.getElementById('ctx-menu').classList.remove('open');
2743
+ const termMenu = document.getElementById('term-ctx-menu');
2744
+ if (termMenu) termMenu.style.display = 'none';
2745
+ });
2508
2746
 
2509
2747
  document.getElementById('ctx-open').onclick = () => { if (!ctxTarget) return; if (isImageFile(ctxTarget.path)) openImageViewer(ctxTarget.path); else loadFiles(ctxTarget.path); };
2510
2748
  document.getElementById('ctx-edit').onclick = () => ctxTarget && openFileEditor(ctxTarget.path);
@@ -3450,6 +3688,9 @@ function toggleSetting(k) {
3450
3688
  const ctrlBtn = document.getElementById('ctrl-toggle-btn');
3451
3689
  if (ctrlBtn) ctrlBtn.style.background = '';
3452
3690
  }
3691
+ if (k === 'bell' && settings.bell && 'Notification' in window && Notification.permission === 'default') {
3692
+ Notification.requestPermission();
3693
+ }
3453
3694
  if (k === 'datasaver') {
3454
3695
  if (settings.datasaver) {
3455
3696
  if (sysStatsTimer) { clearInterval(sysStatsTimer); sysStatsTimer = null; }
@@ -4103,7 +4344,9 @@ function pasteToTerminal() {
4103
4344
  if (!text) return;
4104
4345
  const tab = getActiveTab();
4105
4346
  if (tab?.ws && tab.ws.readyState === WebSocket.OPEN) {
4106
- const enc = new TextEncoder().encode(text);
4347
+ // Bracketed paste: wrap in escape sequences so the shell buffers the input
4348
+ const bracketed = '\x1b[200~' + text + '\x1b[201~';
4349
+ const enc = new TextEncoder().encode(bracketed);
4107
4350
  const buf = new Uint8Array(1 + enc.length);
4108
4351
  buf[0] = 0x00; buf.set(enc, 1);
4109
4352
  tab.ws.send(buf.buffer);
@@ -4947,6 +5190,12 @@ function registerSW() {
4947
5190
  // ═══════════════════════════════════════════════════════
4948
5191
  init().catch(e => console.error('Init failed:', e));
4949
5192
 
5193
+ // Set version in About section
5194
+ fetch('/api/version').then(r => r.json()).then(d => {
5195
+ const el = document.getElementById('about-version');
5196
+ if (el) el.textContent = 'v' + (d.version || '');
5197
+ }).catch(() => {});
5198
+
4950
5199
  // Warn before closing if there are unsaved changes or active terminals
4951
5200
  window.addEventListener('beforeunload', e => {
4952
5201
  const editorOpen = document.getElementById('editor-view').classList.contains('open');