termbeam 1.4.0 → 1.5.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/README.md CHANGED
@@ -79,6 +79,9 @@ termbeam --no-password # disable password protection
79
79
  - **Initial command** — optionally launch a session straight into `htop`, `vim`, or any command
80
80
  - **Shell detection** — auto-detects your shell on all platforms (PowerShell, cmd, bash, zsh, Git Bash, WSL)
81
81
  - **QR code on startup** for instant phone connection
82
+ - **Command completion notifications** — get browser notifications when a command finishes in a background tab; toggle with the bell icon in the toolbar (opt-in, requires browser permission)
83
+ - **Terminal search** — press <kbd>Ctrl+F</kbd> / <kbd>Cmd+F</kbd> to open a search overlay with regex support, powered by xterm.js SearchAddon
84
+ - **Command palette** — press <kbd>Ctrl+K</kbd> / <kbd>Cmd+K</kbd> (or tap the ⚙️ button) to open a slide-out tool panel with categorized actions: Session, Search, View, Share, Notifications, and System
82
85
  - **Light/dark theme** with persistent preference
83
86
  - **Adjustable font size** via status bar controls, saved across sessions
84
87
  - **Port preview** — reverse-proxy a single local web server port and preview it in the browser (HTTP only; no WebSocket/HMR; best with server-rendered apps)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -611,7 +611,7 @@
611
611
  }
612
612
  .theme-wrap {
613
613
  position: relative;
614
- display: flex;
614
+ display: none;
615
615
  align-items: center;
616
616
  }
617
617
  .theme-picker {
@@ -662,7 +662,7 @@
662
662
  height: 30px;
663
663
  border-radius: 8px;
664
664
  cursor: pointer;
665
- display: flex;
665
+ display: none;
666
666
  align-items: center;
667
667
  justify-content: center;
668
668
  gap: 4px;
@@ -685,6 +685,75 @@
685
685
  transform: scale(0.9);
686
686
  }
687
687
 
688
+ /* ===== Notification Toggle ===== */
689
+ .notify-btn.active {
690
+ color: var(--accent) !important;
691
+ }
692
+
693
+ /* ===== Search Bar ===== */
694
+ .search-bar {
695
+ display: none;
696
+ position: absolute;
697
+ top: 4px;
698
+ right: 12px;
699
+ z-index: 100;
700
+ background: var(--surface);
701
+ border: 1px solid var(--border);
702
+ border-radius: 8px;
703
+ padding: 4px 6px;
704
+ gap: 4px;
705
+ align-items: center;
706
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
707
+ font-size: 13px;
708
+ color: var(--text);
709
+ }
710
+ .search-bar.visible {
711
+ display: flex;
712
+ }
713
+ .search-bar input {
714
+ background: var(--bg);
715
+ border: 1px solid var(--border);
716
+ border-radius: 4px;
717
+ color: var(--text);
718
+ padding: 3px 6px;
719
+ font-size: 13px;
720
+ font-family: inherit;
721
+ width: 160px;
722
+ outline: none;
723
+ }
724
+ .search-bar input:focus {
725
+ border-color: var(--accent);
726
+ }
727
+ .search-bar .search-count {
728
+ color: var(--text-secondary);
729
+ font-size: 11px;
730
+ min-width: 40px;
731
+ text-align: center;
732
+ white-space: nowrap;
733
+ }
734
+ .search-bar button {
735
+ background: none;
736
+ border: 1px solid transparent;
737
+ color: var(--text-dim);
738
+ width: 24px;
739
+ height: 24px;
740
+ border-radius: 4px;
741
+ cursor: pointer;
742
+ display: flex;
743
+ align-items: center;
744
+ justify-content: center;
745
+ font-size: 13px;
746
+ padding: 0;
747
+ }
748
+ .search-bar button:hover {
749
+ background: var(--border);
750
+ color: var(--text);
751
+ }
752
+ .search-bar button.active {
753
+ color: var(--accent);
754
+ border-color: var(--accent);
755
+ }
756
+
688
757
  /* ===== Terminals Wrapper ===== */
689
758
  #terminals-wrapper {
690
759
  position: absolute;
@@ -1668,17 +1737,12 @@
1668
1737
  #tab-list {
1669
1738
  display: none;
1670
1739
  }
1671
- #split-toggle {
1672
- display: none;
1673
- }
1674
- #version-text {
1675
- display: none;
1676
- }
1740
+
1677
1741
  #back-btn {
1678
1742
  display: none;
1679
1743
  }
1680
1744
  #theme-wrap {
1681
- display: flex;
1745
+ display: none;
1682
1746
  }
1683
1747
  #stop-btn {
1684
1748
  padding: 0 8px;
@@ -1695,6 +1759,195 @@
1695
1759
  width: auto;
1696
1760
  }
1697
1761
  }
1762
+
1763
+ /* ===== Command Palette / Tool Panel ===== */
1764
+ .palette-backdrop {
1765
+ position: fixed;
1766
+ inset: 0;
1767
+ background: rgba(0, 0, 0, 0.4);
1768
+ z-index: 250;
1769
+ opacity: 0;
1770
+ pointer-events: none;
1771
+ transition: opacity 0.3s;
1772
+ }
1773
+ .palette-backdrop.open {
1774
+ opacity: 1;
1775
+ pointer-events: auto;
1776
+ }
1777
+ .palette-panel {
1778
+ position: fixed;
1779
+ top: 0;
1780
+ right: 0;
1781
+ width: 280px;
1782
+ max-width: 85vw;
1783
+ height: 100%;
1784
+ background: var(--surface);
1785
+ border-left: 1px solid var(--border);
1786
+ z-index: 260;
1787
+ transform: translateX(100%);
1788
+ transition: transform 0.3s ease;
1789
+ display: flex;
1790
+ flex-direction: column;
1791
+ overflow-y: auto;
1792
+ -webkit-overflow-scrolling: touch;
1793
+ }
1794
+ .palette-panel.open {
1795
+ transform: translateX(0);
1796
+ }
1797
+ .palette-header {
1798
+ display: flex;
1799
+ align-items: center;
1800
+ justify-content: space-between;
1801
+ padding: 14px 16px;
1802
+ border-bottom: 1px solid var(--border);
1803
+ font-weight: 600;
1804
+ font-size: 15px;
1805
+ color: var(--text);
1806
+ }
1807
+ .palette-close {
1808
+ background: none;
1809
+ border: none;
1810
+ color: var(--text-secondary);
1811
+ font-size: 18px;
1812
+ cursor: pointer;
1813
+ padding: 4px 8px;
1814
+ border-radius: 6px;
1815
+ }
1816
+ .palette-close:hover {
1817
+ background: var(--hover-bg, rgba(255, 255, 255, 0.08));
1818
+ color: var(--text);
1819
+ }
1820
+ .palette-body {
1821
+ padding: 8px 0;
1822
+ flex: 1;
1823
+ }
1824
+ .palette-category {
1825
+ padding: 8px 16px 4px;
1826
+ font-size: 11px;
1827
+ font-weight: 600;
1828
+ text-transform: uppercase;
1829
+ letter-spacing: 0.5px;
1830
+ color: var(--text-muted, var(--text-secondary));
1831
+ }
1832
+ .palette-action {
1833
+ display: flex;
1834
+ align-items: center;
1835
+ gap: 10px;
1836
+ width: 100%;
1837
+ padding: 10px 16px;
1838
+ background: none;
1839
+ border: none;
1840
+ color: var(--text);
1841
+ font-size: 13px;
1842
+ cursor: pointer;
1843
+ text-align: left;
1844
+ transition: background 0.15s;
1845
+ }
1846
+ .palette-action:hover {
1847
+ background: rgba(255, 255, 255, 0.06);
1848
+ }
1849
+ .palette-action:active {
1850
+ background: rgba(255, 255, 255, 0.1);
1851
+ }
1852
+ [data-theme='light'] .palette-action:hover,
1853
+ [data-theme='solarized-light'] .palette-action:hover {
1854
+ background: rgba(0, 0, 0, 0.06);
1855
+ }
1856
+ [data-theme='light'] .palette-action:active,
1857
+ [data-theme='solarized-light'] .palette-action:active {
1858
+ background: rgba(0, 0, 0, 0.1);
1859
+ }
1860
+ .palette-action-icon {
1861
+ width: 20px;
1862
+ height: 20px;
1863
+ display: flex;
1864
+ align-items: center;
1865
+ justify-content: center;
1866
+ flex-shrink: 0;
1867
+ color: var(--text-secondary);
1868
+ }
1869
+ .palette-action-icon svg {
1870
+ width: 16px;
1871
+ }
1872
+ /* ===== Theme Sub-Panel ===== */
1873
+ .theme-subpanel {
1874
+ display: none;
1875
+ position: fixed;
1876
+ top: 50%;
1877
+ left: 50%;
1878
+ transform: translate(-50%, -50%);
1879
+ width: 240px;
1880
+ max-height: 70vh;
1881
+ background: var(--surface);
1882
+ border: 1px solid var(--border);
1883
+ border-radius: 12px;
1884
+ z-index: 270;
1885
+ overflow-y: auto;
1886
+ -webkit-overflow-scrolling: touch;
1887
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
1888
+ }
1889
+ .theme-subpanel.open {
1890
+ display: block;
1891
+ }
1892
+ .theme-subpanel-header {
1893
+ display: flex;
1894
+ align-items: center;
1895
+ justify-content: space-between;
1896
+ padding: 12px 14px;
1897
+ border-bottom: 1px solid var(--border);
1898
+ font-weight: 600;
1899
+ font-size: 13px;
1900
+ color: var(--text);
1901
+ }
1902
+ .theme-subpanel-close {
1903
+ background: none;
1904
+ border: none;
1905
+ color: var(--text-secondary);
1906
+ font-size: 16px;
1907
+ cursor: pointer;
1908
+ padding: 2px 6px;
1909
+ border-radius: 6px;
1910
+ }
1911
+ .theme-subpanel-close:hover {
1912
+ background: var(--hover-bg, rgba(255, 255, 255, 0.08));
1913
+ color: var(--text);
1914
+ }
1915
+ .theme-subpanel-list {
1916
+ padding: 6px 0;
1917
+ }
1918
+ .theme-subpanel-item {
1919
+ display: flex;
1920
+ align-items: center;
1921
+ gap: 10px;
1922
+ width: 100%;
1923
+ padding: 9px 14px;
1924
+ background: none;
1925
+ border: none;
1926
+ color: var(--text);
1927
+ font-size: 13px;
1928
+ cursor: pointer;
1929
+ text-align: left;
1930
+ transition: background 0.15s;
1931
+ }
1932
+ .theme-subpanel-item:hover {
1933
+ background: rgba(255, 255, 255, 0.06);
1934
+ }
1935
+ [data-theme='light'] .theme-subpanel-item:hover,
1936
+ [data-theme='solarized-light'] .theme-subpanel-item:hover {
1937
+ background: rgba(0, 0, 0, 0.06);
1938
+ }
1939
+ .theme-subpanel-item.active {
1940
+ color: var(--accent);
1941
+ }
1942
+ .theme-subpanel-swatch {
1943
+ width: 14px;
1944
+ height: 14px;
1945
+ border-radius: 50%;
1946
+ flex-shrink: 0;
1947
+ border: 1px solid rgba(128, 128, 128, 0.3);
1948
+ }
1949
+ height: 16px;
1950
+ }
1698
1951
  </style>
1699
1952
  </head>
1700
1953
  <body>
@@ -1792,7 +2045,6 @@
1792
2045
  </div>
1793
2046
  <div id="tab-list"></div>
1794
2047
  <div class="right">
1795
- <span id="version-text" style="font-size: 11px; color: var(--text-muted)"></span>
1796
2048
  <button class="tab-bar-btn" id="tab-new-btn" title="New session">
1797
2049
  <svg
1798
2050
  width="14"
@@ -1807,67 +2059,6 @@
1807
2059
  <line x1="5" y1="12" x2="19" y2="12" /></svg
1808
2060
  ><span class="new-btn-label">New</span>
1809
2061
  </button>
1810
- <button class="tab-bar-btn" id="split-toggle" title="Split view">
1811
- <svg
1812
- width="16"
1813
- height="16"
1814
- viewBox="0 0 24 24"
1815
- fill="none"
1816
- stroke="currentColor"
1817
- stroke-width="2"
1818
- stroke-linecap="round"
1819
- stroke-linejoin="round"
1820
- >
1821
- <rect x="3" y="3" width="18" height="18" rx="2" />
1822
- <line x1="12" y1="3" x2="12" y2="21" />
1823
- </svg>
1824
- </button>
1825
- <div class="bar-group">
1826
- <button class="bar-btn" id="zoom-out" title="Decrease font size">−</button>
1827
- <button class="bar-btn" id="zoom-in" title="Increase font size">+</button>
1828
- </div>
1829
- <button
1830
- class="bar-btn"
1831
- id="preview-btn"
1832
- title="Preview local port"
1833
- onclick="openPreviewModal()"
1834
- >
1835
- 🌐
1836
- </button>
1837
- <button class="bar-btn" id="share-btn" title="Share link">
1838
- <svg
1839
- width="16"
1840
- height="16"
1841
- viewBox="0 0 24 24"
1842
- fill="none"
1843
- stroke="currentColor"
1844
- stroke-width="2"
1845
- stroke-linecap="round"
1846
- stroke-linejoin="round"
1847
- >
1848
- <circle cx="18" cy="5" r="3" />
1849
- <circle cx="6" cy="12" r="3" />
1850
- <circle cx="18" cy="19" r="3" />
1851
- <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
1852
- <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
1853
- </svg>
1854
- </button>
1855
- <button class="bar-btn" id="refresh-btn" title="Refresh app">
1856
- <svg
1857
- width="16"
1858
- height="16"
1859
- viewBox="0 0 24 24"
1860
- fill="none"
1861
- stroke="currentColor"
1862
- stroke-width="2"
1863
- stroke-linecap="round"
1864
- stroke-linejoin="round"
1865
- >
1866
- <polyline points="23 4 23 10 17 10" />
1867
- <polyline points="1 20 1 14 7 14" />
1868
- <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
1869
- </svg>
1870
- </button>
1871
2062
  <div class="theme-wrap" id="theme-wrap">
1872
2063
  <button class="bar-btn" id="theme-toggle" title="Switch theme">
1873
2064
  <svg
@@ -1928,6 +2119,23 @@
1928
2119
  </div>
1929
2120
  </div>
1930
2121
  </div>
2122
+ <button class="bar-btn" id="palette-trigger" title="Tools (Ctrl+K)">
2123
+ <svg
2124
+ width="16"
2125
+ height="16"
2126
+ viewBox="0 0 24 24"
2127
+ fill="none"
2128
+ stroke="currentColor"
2129
+ stroke-width="2"
2130
+ stroke-linecap="round"
2131
+ stroke-linejoin="round"
2132
+ >
2133
+ <rect x="3" y="3" width="7" height="7" rx="1" />
2134
+ <rect x="14" y="3" width="7" height="7" rx="1" />
2135
+ <rect x="3" y="14" width="7" height="7" rx="1" />
2136
+ <rect x="14" y="14" width="7" height="7" rx="1" />
2137
+ </svg>
2138
+ </button>
1931
2139
  <button id="stop-btn" title="Stop session">
1932
2140
  <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
1933
2141
  <rect x="6" y="6" width="12" height="12" rx="2" /></svg
@@ -1946,7 +2154,16 @@
1946
2154
  </div>
1947
2155
 
1948
2156
  <!-- Terminals Wrapper (panes created dynamically) -->
1949
- <div id="terminals-wrapper"></div>
2157
+ <div id="terminals-wrapper">
2158
+ <div class="search-bar" id="search-bar">
2159
+ <input type="text" id="search-input" placeholder="Search…" autocomplete="off" />
2160
+ <span class="search-count" id="search-count"></span>
2161
+ <button id="search-regex" title="Regex">.*</button>
2162
+ <button id="search-prev" title="Previous">▲</button>
2163
+ <button id="search-next" title="Next">▼</button>
2164
+ <button id="search-close" title="Close">✕</button>
2165
+ </div>
2166
+ </div>
1950
2167
 
1951
2168
  <div id="copy-toast">Copied!</div>
1952
2169
 
@@ -1965,7 +2182,7 @@
1965
2182
  <div class="key-row">
1966
2183
  <button class="key-btn modifier" id="ctrl-btn" title="Toggle Ctrl modifier">Ctrl</button>
1967
2184
  <button class="key-btn modifier" id="shift-btn" title="Toggle Shift modifier">Shift</button>
1968
- <button class="key-btn special" data-key="&#x09;" title="Autocomplete">Tab</button>
2185
+ <button class="key-btn special" data-key="tab" title="Autocomplete">Tab</button>
1969
2186
  <button class="key-btn special key-danger" data-key="&#x03;" title="Interrupt process">
1970
2187
  ^C
1971
2188
  </button>
@@ -2212,9 +2429,29 @@
2212
2429
  </div>
2213
2430
  </div>
2214
2431
 
2432
+ <!-- Command Palette / Tool Panel -->
2433
+ <div id="palette-backdrop" class="palette-backdrop"></div>
2434
+ <div id="palette-panel" class="palette-panel">
2435
+ <div class="palette-header">
2436
+ <span>Tools</span>
2437
+ <button class="palette-close" id="palette-close">✕</button>
2438
+ </div>
2439
+ <div class="palette-body" id="palette-body"></div>
2440
+ </div>
2441
+
2442
+ <!-- Theme Sub-Panel -->
2443
+ <div class="theme-subpanel" id="theme-subpanel">
2444
+ <div class="theme-subpanel-header">
2445
+ <span>Theme</span>
2446
+ <button class="theme-subpanel-close" id="theme-subpanel-close">✕</button>
2447
+ </div>
2448
+ <div class="theme-subpanel-list" id="theme-subpanel-list"></div>
2449
+ </div>
2450
+
2215
2451
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
2216
2452
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
2217
2453
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
2454
+ <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
2218
2455
  <script>
2219
2456
  // ===== Constants =====
2220
2457
  const SESSION_COLORS = [
@@ -2235,6 +2472,33 @@
2235
2472
 
2236
2473
  let splitSecondId = null;
2237
2474
 
2475
+ // ===== Notification State =====
2476
+ let notificationsEnabled = localStorage.getItem('termbeam-notifications') !== 'false';
2477
+
2478
+ function updateNotifyToggle() {
2479
+ // notify-toggle button removed from top bar; function kept for palette use
2480
+ }
2481
+
2482
+ function sendCommandNotification(sessionName) {
2483
+ if (Notification.permission !== 'granted') return;
2484
+ try {
2485
+ new Notification('Command finished in ' + sessionName, {
2486
+ icon: '/icons/icon-192.png',
2487
+ tag: 'termbeam-cmd',
2488
+ });
2489
+ } catch {}
2490
+ }
2491
+
2492
+ function resetSilenceTimer(ms) {
2493
+ if (ms.silenceTimer) clearTimeout(ms.silenceTimer);
2494
+ ms.silenceTimer = setTimeout(() => {
2495
+ ms.silenceTimer = null;
2496
+ if (document.hidden && notificationsEnabled) {
2497
+ sendCommandNotification(ms.name || ms.id);
2498
+ }
2499
+ }, 3000);
2500
+ }
2501
+
2238
2502
  // Clipboard copy fallback for non-secure contexts (HTTP over LAN)
2239
2503
  function copyFallback(text) {
2240
2504
  const ta = document.createElement('textarea');
@@ -2592,10 +2856,98 @@
2592
2856
  el.addEventListener('click', (e) => {
2593
2857
  e.stopPropagation();
2594
2858
  applyTheme(el.dataset.themeOption);
2595
- document.getElementById('theme-picker').classList.remove('open');
2596
2859
  });
2597
2860
  });
2598
2861
 
2862
+ // ===== Terminal Search =====
2863
+ const searchBar = document.getElementById('search-bar');
2864
+ const searchInput = document.getElementById('search-input');
2865
+ const searchCount = document.getElementById('search-count');
2866
+ const searchRegexBtn = document.getElementById('search-regex');
2867
+ let searchRegex = false;
2868
+ let searchResultIndex = 0;
2869
+ let searchResultTotal = 0;
2870
+
2871
+ function getActiveSearchAddon() {
2872
+ if (!activeId) return null;
2873
+ const ms = managed.get(activeId);
2874
+ return ms ? ms.searchAddon : null;
2875
+ }
2876
+
2877
+ function updateSearchCount(idx, total) {
2878
+ searchResultIndex = idx;
2879
+ searchResultTotal = total;
2880
+ searchCount.textContent = total > 0 ? idx + 1 + ' of ' + total : 'No results';
2881
+ }
2882
+
2883
+ function doSearch(direction) {
2884
+ const addon = getActiveSearchAddon();
2885
+ if (!addon) return;
2886
+ const query = searchInput.value;
2887
+ if (!query) {
2888
+ searchCount.textContent = '';
2889
+ return;
2890
+ }
2891
+ const opts = {
2892
+ regex: searchRegex,
2893
+ caseSensitive: false,
2894
+ incremental: direction === 'next',
2895
+ };
2896
+ let result;
2897
+ if (direction === 'prev') {
2898
+ result = addon.findPrevious(query, opts);
2899
+ } else {
2900
+ result = addon.findNext(query, opts);
2901
+ }
2902
+ // SearchAddon returns boolean; no match count API in v0.15
2903
+ searchCount.textContent = result ? 'Found' : 'No results';
2904
+ }
2905
+
2906
+ function openSearchBar() {
2907
+ searchBar.classList.add('visible');
2908
+ searchInput.focus();
2909
+ searchInput.select();
2910
+ }
2911
+
2912
+ function closeSearchBar() {
2913
+ searchBar.classList.remove('visible');
2914
+ searchCount.textContent = '';
2915
+ searchInput.value = '';
2916
+ const addon = getActiveSearchAddon();
2917
+ if (addon) addon.clearDecorations();
2918
+ // Re-focus terminal
2919
+ if (activeId) {
2920
+ const ms = managed.get(activeId);
2921
+ if (ms) ms.term.focus();
2922
+ }
2923
+ }
2924
+
2925
+ searchInput.addEventListener('input', () => doSearch('next'));
2926
+ searchInput.addEventListener('keydown', (e) => {
2927
+ if (e.key === 'Escape') {
2928
+ closeSearchBar();
2929
+ e.preventDefault();
2930
+ } else if (e.key === 'Enter') {
2931
+ e.preventDefault();
2932
+ doSearch(e.shiftKey ? 'prev' : 'next');
2933
+ }
2934
+ });
2935
+ document.getElementById('search-next').addEventListener('click', () => doSearch('next'));
2936
+ document.getElementById('search-prev').addEventListener('click', () => doSearch('prev'));
2937
+ document.getElementById('search-close').addEventListener('click', closeSearchBar);
2938
+ document.getElementById('search-regex').addEventListener('click', () => {
2939
+ searchRegex = !searchRegex;
2940
+ searchRegexBtn.classList.toggle('active', searchRegex);
2941
+ if (searchInput.value) doSearch('next');
2942
+ });
2943
+
2944
+ document.addEventListener('keydown', (e) => {
2945
+ if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
2946
+ e.preventDefault();
2947
+ openSearchBar();
2948
+ }
2949
+ });
2950
+
2599
2951
  // ===== Font Loading (non-blocking) =====
2600
2952
  const nerdFont = new FontFace(
2601
2953
  'NerdFont',
@@ -2731,12 +3083,6 @@
2731
3083
  loadShellsForModal();
2732
3084
  startPolling();
2733
3085
 
2734
- // Zoom
2735
- document.getElementById('zoom-in').addEventListener('click', () => applyZoom(fontSize + 2));
2736
- document
2737
- .getElementById('zoom-out')
2738
- .addEventListener('click', () => applyZoom(fontSize - 2));
2739
-
2740
3086
  // Pinch-to-zoom
2741
3087
  (function setupPinchZoom() {
2742
3088
  const wrapper = document.getElementById('terminals-wrapper');
@@ -2864,9 +3210,6 @@
2864
3210
  );
2865
3211
  }
2866
3212
 
2867
- // Split toggle
2868
- document.getElementById('split-toggle').addEventListener('click', toggleSplit);
2869
-
2870
3213
  // Scroll to bottom when returning from idle / tab switch
2871
3214
  document.addEventListener('visibilitychange', () => {
2872
3215
  if (!document.hidden && activeId) {
@@ -2914,7 +3257,7 @@
2914
3257
  fetch('/api/version')
2915
3258
  .then((r) => r.json())
2916
3259
  .then((d) => {
2917
- document.getElementById('version-text').textContent = 'v' + d.version;
3260
+ window._termbeamVersion = 'v' + d.version;
2918
3261
  document.getElementById('side-panel-version').textContent = 'v' + d.version;
2919
3262
  })
2920
3263
  .catch(() => {});
@@ -2923,6 +3266,7 @@
2923
3266
  // ===== Session Management =====
2924
3267
  function addSession(data) {
2925
3268
  if (managed.has(data.id)) return;
3269
+ managed.set(data.id, null); // reserve slot to prevent race condition
2926
3270
 
2927
3271
  const term = new window.Terminal({
2928
3272
  cursorBlink: true,
@@ -2941,8 +3285,10 @@
2941
3285
 
2942
3286
  const fitAddon = new window.FitAddon.FitAddon();
2943
3287
  const webLinksAddon = new window.WebLinksAddon.WebLinksAddon();
3288
+ const searchAddon = new window.SearchAddon.SearchAddon();
2944
3289
  term.loadAddon(fitAddon);
2945
3290
  term.loadAddon(webLinksAddon);
3291
+ term.loadAddon(searchAddon);
2946
3292
 
2947
3293
  const container = document.createElement('div');
2948
3294
  container.className = 'terminal-pane';
@@ -3092,6 +3438,7 @@
3092
3438
  pid: data.pid,
3093
3439
  term,
3094
3440
  fitAddon,
3441
+ searchAddon,
3095
3442
  container,
3096
3443
  coalescedWrite,
3097
3444
  scrollBtn,
@@ -3100,6 +3447,7 @@
3100
3447
  reconnectTimer: null,
3101
3448
  reconnectDelay: 3000,
3102
3449
  lastActivity: data.lastActivity || Date.now(),
3450
+ silenceTimer: null,
3103
3451
  };
3104
3452
 
3105
3453
  managed.set(data.id, ms);
@@ -3211,6 +3559,7 @@
3211
3559
  if (msg.type === 'output') {
3212
3560
  ms.coalescedWrite(msg.data);
3213
3561
  ms.lastActivity = Date.now();
3562
+ resetSilenceTimer(ms);
3214
3563
  markUnreadOutput();
3215
3564
  if (ms.id !== activeId && !ms.hasUnread) {
3216
3565
  ms.hasUnread = true;
@@ -3331,6 +3680,10 @@
3331
3680
  clearTimeout(ms.reconnectTimer);
3332
3681
  ms.reconnectTimer = null;
3333
3682
  }
3683
+ if (ms.silenceTimer) {
3684
+ clearTimeout(ms.silenceTimer);
3685
+ ms.silenceTimer = null;
3686
+ }
3334
3687
  if (ms.ws)
3335
3688
  try {
3336
3689
  ms.ws.close();
@@ -3717,7 +4070,6 @@
3717
4070
  // ===== Split View =====
3718
4071
  function toggleSplit() {
3719
4072
  splitMode = !splitMode;
3720
- document.getElementById('split-toggle').classList.toggle('active', splitMode);
3721
4073
 
3722
4074
  if (splitMode) {
3723
4075
  // Find a second session to show
@@ -3804,7 +4156,12 @@
3804
4156
  function sendKey(btn) {
3805
4157
  if (!btn || !btn.dataset.key) return;
3806
4158
  flashBtn(btn);
3807
- let data = btn.dataset.key === 'enter' ? '\r' : btn.dataset.key;
4159
+ let data =
4160
+ btn.dataset.key === 'enter'
4161
+ ? '\r'
4162
+ : btn.dataset.key === 'tab'
4163
+ ? '\x09'
4164
+ : btn.dataset.key;
3808
4165
  data = applyModifiers(data);
3809
4166
  const ms = managed.get(activeId);
3810
4167
  if (ms && ms.ws && ms.ws.readyState === 1) {
@@ -4337,6 +4694,18 @@
4337
4694
  if (cmd) body.initialCommand = cmd;
4338
4695
  if (color) body.color = color;
4339
4696
 
4697
+ // Include current terminal dimensions so the PTY spawns at the right
4698
+ // size — prevents oh-my-posh and other slow prompts from rendering
4699
+ // at the default 120×30 size and triggering a duplicate on SIGWINCH.
4700
+ const activeMs = managed.get(activeId);
4701
+ if (activeMs && activeMs.fitAddon) {
4702
+ const dims = activeMs.fitAddon.proposeDimensions();
4703
+ if (dims) {
4704
+ body.cols = dims.cols;
4705
+ body.rows = dims.rows;
4706
+ }
4707
+ }
4708
+
4340
4709
  try {
4341
4710
  const res = await fetch('/api/sessions', {
4342
4711
  method: 'POST',
@@ -4391,6 +4760,10 @@
4391
4760
  clearTimeout(ms.reconnectTimer);
4392
4761
  ms.reconnectTimer = null;
4393
4762
  }
4763
+ if (ms.silenceTimer) {
4764
+ clearTimeout(ms.silenceTimer);
4765
+ ms.silenceTimer = null;
4766
+ }
4394
4767
  if (ms.ws)
4395
4768
  try {
4396
4769
  ms.ws.close();
@@ -4524,12 +4897,11 @@
4524
4897
  });
4525
4898
  }
4526
4899
 
4527
- document.getElementById('share-btn').addEventListener('click', async () => {
4900
+ async function shareLink() {
4528
4901
  const urlPromise = fetch('/api/share-token')
4529
4902
  .then((r) => (r.ok ? r.json() : null))
4530
4903
  .then((data) => (data && data.url) || location.href)
4531
4904
  .catch(() => location.href);
4532
- // ClipboardItem with a promise preserves user activation across the fetch
4533
4905
  if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
4534
4906
  try {
4535
4907
  const blobPromise = urlPromise.then((u) => new Blob([u], { type: 'text/plain' }));
@@ -4538,7 +4910,6 @@
4538
4910
  return;
4539
4911
  } catch {}
4540
4912
  }
4541
- // Fallback: resolve URL first, then try legacy methods
4542
4913
  const url = await urlPromise;
4543
4914
  if (navigator.clipboard && navigator.clipboard.writeText) {
4544
4915
  try {
@@ -4552,10 +4923,9 @@
4552
4923
  } else {
4553
4924
  showShareUrlPrompt(url);
4554
4925
  }
4555
- });
4926
+ }
4556
4927
 
4557
- // ===== Refresh Button =====
4558
- document.getElementById('refresh-btn').addEventListener('click', async () => {
4928
+ async function refreshApp() {
4559
4929
  if ('caches' in window) {
4560
4930
  const keys = await caches.keys();
4561
4931
  await Promise.all(keys.map((k) => caches.delete(k)));
@@ -4565,7 +4935,294 @@
4565
4935
  if (reg) await reg.update();
4566
4936
  }
4567
4937
  location.reload();
4568
- });
4938
+ }
4939
+
4940
+ // ===== Command Palette =====
4941
+ (function setupPalette() {
4942
+ const paletteActions = [
4943
+ {
4944
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
4945
+ label: 'New tab',
4946
+ category: 'Session',
4947
+ action: () => openNewSessionModal(),
4948
+ },
4949
+ {
4950
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
4951
+ label: 'Close tab',
4952
+ category: 'Session',
4953
+ action: () => {
4954
+ if (!activeId) return;
4955
+ const ms = managed.get(activeId);
4956
+ const name = (ms && ms.name) || activeId.slice(0, 8);
4957
+ if (confirm('Close session "' + name + '"?')) {
4958
+ removeSession(activeId);
4959
+ }
4960
+ },
4961
+ },
4962
+ {
4963
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>',
4964
+ label: 'Rename session',
4965
+ category: 'Session',
4966
+ action: () => {
4967
+ if (!activeId) return;
4968
+ const ms = managed.get(activeId);
4969
+ if (!ms) return;
4970
+ const name = prompt('Rename session:', ms.name || '');
4971
+ if (name !== null && name.trim()) {
4972
+ ms.name = name.trim();
4973
+ renderTabs();
4974
+ updateStatusBar();
4975
+ }
4976
+ },
4977
+ },
4978
+ {
4979
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>',
4980
+ label: 'Split view',
4981
+ category: 'Session',
4982
+ action: () => toggleSplit(),
4983
+ },
4984
+ {
4985
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>',
4986
+ label: 'Stop session',
4987
+ category: 'Session',
4988
+ action: () => {
4989
+ if (!activeId) return;
4990
+ if (!confirm('Stop this session? The process will be killed.')) return;
4991
+ removeSession(activeId);
4992
+ },
4993
+ },
4994
+ {
4995
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
4996
+ label: 'Find in terminal',
4997
+ category: 'Search',
4998
+ action: () => openSearchBar(),
4999
+ },
5000
+ {
5001
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>',
5002
+ label: 'Increase font size',
5003
+ category: 'View',
5004
+ action: () => applyZoom(fontSize + 1),
5005
+ },
5006
+ {
5007
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></svg>',
5008
+ label: 'Decrease font size',
5009
+ category: 'View',
5010
+ action: () => applyZoom(fontSize - 1),
5011
+ },
5012
+ {
5013
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>',
5014
+ get label() {
5015
+ const current = THEMES.find((x) => x.id === getTheme()) || THEMES[0];
5016
+ return 'Theme (' + current.name + ')';
5017
+ },
5018
+ category: 'View',
5019
+ action: () => {
5020
+ openThemeSubpanel();
5021
+ },
5022
+ },
5023
+ {
5024
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
5025
+ label: 'Preview port',
5026
+ category: 'View',
5027
+ action: () => openPreviewModal(),
5028
+ },
5029
+ {
5030
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
5031
+ label: 'Copy link',
5032
+ category: 'Share',
5033
+ action: () => {
5034
+ const url = location.href;
5035
+ if (navigator.clipboard && navigator.clipboard.writeText) {
5036
+ navigator.clipboard.writeText(url).then(
5037
+ () => showToast('Link copied!'),
5038
+ () => {
5039
+ if (copyToClipboardFallback(url)) showToast('Link copied!');
5040
+ else showShareUrlPrompt(url);
5041
+ },
5042
+ );
5043
+ } else if (copyToClipboardFallback(url)) {
5044
+ showToast('Link copied!');
5045
+ } else {
5046
+ showShareUrlPrompt(url);
5047
+ }
5048
+ },
5049
+ },
5050
+ {
5051
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>',
5052
+ get label() {
5053
+ return notificationsEnabled ? 'Notifications (on)' : 'Notifications (off)';
5054
+ },
5055
+ category: 'Notifications',
5056
+ keepOpen: true,
5057
+ action: () => {
5058
+ notificationsEnabled = !notificationsEnabled;
5059
+ localStorage.setItem('termbeam-notifications', notificationsEnabled);
5060
+ if (
5061
+ notificationsEnabled &&
5062
+ 'Notification' in window &&
5063
+ Notification.permission === 'default'
5064
+ ) {
5065
+ Notification.requestPermission();
5066
+ }
5067
+ showToast(notificationsEnabled ? 'Notifications on' : 'Notifications off');
5068
+ renderPalette();
5069
+ },
5070
+ },
5071
+ {
5072
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>',
5073
+ label: 'Refresh',
5074
+ category: 'System',
5075
+ action: () => refreshApp(),
5076
+ },
5077
+ {
5078
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><line x1="18" y1="9" x2="12" y2="15"/><line x1="12" y1="9" x2="18" y2="15"/></svg>',
5079
+ label: 'Clear terminal',
5080
+ category: 'System',
5081
+ action: () => {
5082
+ if (!activeId) return;
5083
+ const ms = managed.get(activeId);
5084
+ if (ms) ms.term.clear();
5085
+ },
5086
+ },
5087
+ {
5088
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
5089
+ label: 'About',
5090
+ category: 'System',
5091
+ action: () => {
5092
+ const ver = window._termbeamVersion || 'TermBeam';
5093
+ const overlay = document.createElement('div');
5094
+ overlay.style.cssText =
5095
+ 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:300;display:flex;align-items:center;justify-content:center;';
5096
+ const box = document.createElement('div');
5097
+ box.style.cssText =
5098
+ 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:90vw;width:320px;text-align:center;';
5099
+ box.innerHTML =
5100
+ '<div style="font-size:24px;margin-bottom:8px;">⚡</div>' +
5101
+ '<div style="font-size:16px;font-weight:600;color:var(--text);margin-bottom:4px;">TermBeam</div>' +
5102
+ '<div style="font-size:13px;color:var(--text-secondary);margin-bottom:16px;">' +
5103
+ esc(ver) +
5104
+ '</div>' +
5105
+ '<div style="font-size:12px;color:var(--text-secondary);margin-bottom:16px;">Terminal in your browser, optimized for mobile.</div>' +
5106
+ '<div style="display:flex;gap:16px;justify-content:center;margin-bottom:16px;">' +
5107
+ '<a href="https://github.com/dorlugasigal/TermBeam" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">GitHub</a>' +
5108
+ '<a href="https://dorlugasigal.github.io/TermBeam/" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Docs</a>' +
5109
+ '<a href="https://termbeam.pages.dev" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Website</a>' +
5110
+ '</div>';
5111
+ const btn = document.createElement('button');
5112
+ btn.textContent = 'Close';
5113
+ btn.style.cssText =
5114
+ 'padding:6px 20px;border-radius:6px;border:none;background:var(--accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;';
5115
+ btn.onclick = () => overlay.remove();
5116
+ box.appendChild(btn);
5117
+ overlay.appendChild(box);
5118
+ overlay.addEventListener('click', (e) => {
5119
+ if (e.target === overlay) overlay.remove();
5120
+ });
5121
+ document.body.appendChild(overlay);
5122
+ },
5123
+ },
5124
+ ];
5125
+
5126
+ const backdrop = document.getElementById('palette-backdrop');
5127
+ const panel = document.getElementById('palette-panel');
5128
+ const body = document.getElementById('palette-body');
5129
+
5130
+ function renderPalette() {
5131
+ const grouped = {};
5132
+ paletteActions.forEach((a) => {
5133
+ if (!grouped[a.category]) grouped[a.category] = [];
5134
+ grouped[a.category].push(a);
5135
+ });
5136
+ body.innerHTML = '';
5137
+ Object.keys(grouped).forEach((cat) => {
5138
+ const header = document.createElement('div');
5139
+ header.className = 'palette-category';
5140
+ header.textContent = cat;
5141
+ body.appendChild(header);
5142
+ grouped[cat].forEach((a) => {
5143
+ const btn = document.createElement('button');
5144
+ btn.className = 'palette-action';
5145
+ btn.innerHTML =
5146
+ '<span class="palette-action-icon">' + a.icon + '</span>' + esc(a.label);
5147
+ btn.addEventListener('click', () => {
5148
+ if (!a.keepOpen) closePalette();
5149
+ a.action();
5150
+ });
5151
+ body.appendChild(btn);
5152
+ });
5153
+ });
5154
+ }
5155
+
5156
+ function openPalette() {
5157
+ backdrop.classList.add('open');
5158
+ panel.classList.add('open');
5159
+ }
5160
+
5161
+ function closePalette() {
5162
+ backdrop.classList.remove('open');
5163
+ panel.classList.remove('open');
5164
+ }
5165
+
5166
+ function togglePalette() {
5167
+ if (panel.classList.contains('open')) closePalette();
5168
+ else openPalette();
5169
+ }
5170
+
5171
+ backdrop.addEventListener('click', closePalette);
5172
+ document.getElementById('palette-close').addEventListener('click', closePalette);
5173
+ document.getElementById('palette-trigger').addEventListener('click', togglePalette);
5174
+
5175
+ document.addEventListener('keydown', (e) => {
5176
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
5177
+ e.preventDefault();
5178
+ togglePalette();
5179
+ }
5180
+ if (e.key === 'Escape' && panel.classList.contains('open')) {
5181
+ closePalette();
5182
+ }
5183
+ });
5184
+
5185
+ renderPalette();
5186
+ })();
5187
+
5188
+ // ===== Theme Sub-Panel =====
5189
+ (function setupThemeSubpanel() {
5190
+ const subpanel = document.getElementById('theme-subpanel');
5191
+ const list = document.getElementById('theme-subpanel-list');
5192
+ document.getElementById('theme-subpanel-close').addEventListener('click', () => {
5193
+ subpanel.classList.remove('open');
5194
+ });
5195
+ function renderThemeList() {
5196
+ const cur = getTheme();
5197
+ list.innerHTML = THEMES.map(
5198
+ (t) =>
5199
+ '<button class="theme-subpanel-item' +
5200
+ (t.id === cur ? ' active' : '') +
5201
+ '" data-tid="' +
5202
+ t.id +
5203
+ '"><span class="theme-subpanel-swatch" style="background:' +
5204
+ t.bg +
5205
+ '"></span>' +
5206
+ esc(t.name) +
5207
+ '</button>',
5208
+ ).join('');
5209
+ list.querySelectorAll('.theme-subpanel-item').forEach((btn) => {
5210
+ btn.addEventListener('click', () => {
5211
+ applyTheme(btn.dataset.tid);
5212
+ renderThemeList();
5213
+ });
5214
+ });
5215
+ }
5216
+ window.openThemeSubpanel = function () {
5217
+ renderThemeList();
5218
+ subpanel.classList.add('open');
5219
+ };
5220
+ document.addEventListener('keydown', (e) => {
5221
+ if (e.key === 'Escape' && subpanel.classList.contains('open')) {
5222
+ subpanel.classList.remove('open');
5223
+ }
5224
+ });
5225
+ })();
4569
5226
 
4570
5227
  // ===== Service Worker =====
4571
5228
  if ('serviceWorker' in navigator) {
package/src/routes.js CHANGED
@@ -114,7 +114,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
114
114
  });
115
115
 
116
116
  app.post('/api/sessions', auth.middleware, (req, res) => {
117
- const { name, shell, args: shellArgs, cwd, initialCommand, color } = req.body || {};
117
+ const { name, shell, args: shellArgs, cwd, initialCommand, color, cols, rows } = req.body || {};
118
118
 
119
119
  // Validate shell field
120
120
  if (shell) {
@@ -146,6 +146,8 @@ function setupRoutes(app, { auth, sessions, config, state }) {
146
146
  cwd: cwd || config.cwd,
147
147
  initialCommand: initialCommand || null,
148
148
  color: color || null,
149
+ cols: typeof cols === 'number' && cols > 0 && cols <= 500 ? Math.floor(cols) : undefined,
150
+ rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
149
151
  });
150
152
  res.json({ id, url: `/terminal?id=${id}` });
151
153
  });
@@ -153,7 +155,9 @@ function setupRoutes(app, { auth, sessions, config, state }) {
153
155
  // Available shells
154
156
  app.get('/api/shells', auth.middleware, (_req, res) => {
155
157
  const shells = detectShells();
156
- res.json({ shells, default: config.defaultShell, cwd: config.cwd });
158
+ const ds = config.defaultShell;
159
+ const match = shells.find((s) => s.cmd === ds || s.path === ds || s.name === ds);
160
+ res.json({ shells, default: match ? match.cmd : ds, cwd: config.cwd });
157
161
  });
158
162
 
159
163
  app.get('/api/sessions/:id/detect-port', auth.middleware, (req, res) => {
package/src/sessions.js CHANGED
@@ -18,15 +18,24 @@ class SessionManager {
18
18
  this.sessions = new Map();
19
19
  }
20
20
 
21
- create({ name, shell, args = [], cwd, initialCommand = null, color = null }) {
21
+ create({
22
+ name,
23
+ shell,
24
+ args = [],
25
+ cwd,
26
+ initialCommand = null,
27
+ color = null,
28
+ cols = 120,
29
+ rows = 30,
30
+ }) {
22
31
  const id = crypto.randomBytes(16).toString('hex');
23
32
  if (!color) {
24
33
  color = SESSION_COLORS[this.sessions.size % SESSION_COLORS.length];
25
34
  }
26
35
  const ptyProcess = pty.spawn(shell, args, {
27
36
  name: 'xterm-256color',
28
- cols: 120,
29
- rows: 30,
37
+ cols,
38
+ rows,
30
39
  cwd,
31
40
  env: { ...process.env, TERM: 'xterm-256color' },
32
41
  });
@@ -47,6 +56,9 @@ class SessionManager {
47
56
  clients: new Set(),
48
57
  scrollback: [],
49
58
  scrollbackBuf: '',
59
+ hasHadClient: false,
60
+ _lastCols: cols,
61
+ _lastRows: rows,
50
62
  };
51
63
 
52
64
  ptyProcess.onData((data) => {
package/src/websocket.js CHANGED
@@ -77,9 +77,16 @@ function setupWebSocket(wss, { auth, sessions }) {
77
77
  return;
78
78
  }
79
79
  attached = session;
80
- session.clients.add(ws);
81
- if (session.scrollbackBuf.length > 0) {
82
- ws.send(JSON.stringify({ type: 'output', data: session.scrollbackBuf }));
80
+ // First client: defer adding to session.clients until after the
81
+ // first resize so we can decide whether the PTY needs resizing.
82
+ if (!session.hasHadClient) {
83
+ session.hasHadClient = true;
84
+ ws._pendingResize = true;
85
+ } else {
86
+ session.clients.add(ws);
87
+ if (session.scrollbackBuf.length > 0) {
88
+ ws.send(JSON.stringify({ type: 'output', data: session.scrollbackBuf }));
89
+ }
83
90
  }
84
91
  ws.send(JSON.stringify({ type: 'attached', sessionId: msg.sessionId }));
85
92
  log.info(`Client attached to session ${msg.sessionId}`);
@@ -95,7 +102,27 @@ function setupWebSocket(wss, { auth, sessions }) {
95
102
  const rows = Math.floor(msg.rows);
96
103
  if (cols > 0 && cols <= 500 && rows > 0 && rows <= 200) {
97
104
  ws._dims = { cols, rows };
98
- recalcPtySize(attached);
105
+ if (ws._pendingResize) {
106
+ ws._pendingResize = false;
107
+ // Only discard scrollback and send SIGWINCH if the PTY was
108
+ // spawned at a different size (e.g. default 120×30).
109
+ // If the PTY already matches (new session sent dims in POST),
110
+ // just add the client and replay scrollback — no SIGWINCH,
111
+ // no duplicate prompt from slow themes like oh-my-posh.
112
+ const sizeChanged = cols !== attached._lastCols || rows !== attached._lastRows;
113
+ if (sizeChanged) {
114
+ attached.scrollbackBuf = '';
115
+ attached.clients.add(ws);
116
+ recalcPtySize(attached);
117
+ } else {
118
+ attached.clients.add(ws);
119
+ if (attached.scrollbackBuf.length > 0) {
120
+ ws.send(JSON.stringify({ type: 'output', data: attached.scrollbackBuf }));
121
+ }
122
+ }
123
+ } else {
124
+ recalcPtySize(attached);
125
+ }
99
126
  }
100
127
  }
101
128
  } catch (err) {