termbeam 0.0.8 → 0.1.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
@@ -41,8 +41,18 @@ termbeam --password mysecret
41
41
 
42
42
  ## Features
43
43
 
44
- - **Mobile-first UI** with on-screen touch bar (arrow keys, Tab, Ctrl shortcuts, Esc) and swipe-to-delete session management
45
- - **Multiple sessions** running simultaneously, managed from a single hub page shows connected client count per session
44
+ - **Mobile-first UI** with on-screen touch bar (arrow keys, Tab, Enter, Ctrl shortcuts, Esc) and touch-optimized controls
45
+ - **Tabbed multi-session terminal** open, switch, and manage multiple sessions from a single tab bar with drag-to-reorder
46
+ - **Split view** — view two sessions side-by-side (horizontal on desktop, vertical on mobile)
47
+ - **Session colors** — assign a color to each session for quick identification
48
+ - **Activity indicators** — see how recently each session had output (e.g. "3s ago", "5m ago")
49
+ - **Tab previews** — hover (desktop) or long-press (mobile) a tab to preview the last few lines of output
50
+ - **Side panel** (mobile) — slide-out session list with output previews for quick switching
51
+ - **Create sessions anywhere** — new session modal available from both the hub page and the terminal page
52
+ - **Touch scrolling** — swipe to scroll through terminal history
53
+ - **Share button** — share the TermBeam URL via Web Share API, clipboard, or legacy copy fallback (works over HTTP)
54
+ - **Refresh button** — clear PWA/service worker cache and reload to get the latest version
55
+ - **iPhone PWA safe area** — full support for `viewport-fit=cover` and safe area insets on notched devices
46
56
  - **Password auth** with token-based cookies and rate-limited login
47
57
  - **Folder browser** to pick working directories without typing paths
48
58
  - **Initial command** — optionally launch a session straight into `htop`, `vim`, or any command
@@ -80,14 +90,14 @@ termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
80
90
 
81
91
  | Flag | Description | Default |
82
92
  | --------------------- | ---------------------------------------- | ----------- |
83
- | `--password <pw>` | Set access password | None |
93
+ | `--password <pw>` | Set access password (also accepts `--password=<pw>`) | None |
84
94
  | `--generate-password` | Auto-generate a secure password | — |
85
95
  | `--tunnel` | Create an ephemeral devtunnel URL | Off |
86
96
  | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
87
97
  | `--port <port>` | Server port | `3456` |
88
98
  | `--host <addr>` | Bind address | `0.0.0.0` |
89
99
 
90
- Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD` (see [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/)).
100
+ Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
91
101
 
92
102
  ## Security
93
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "0.0.8",
3
+ "version": "0.1.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": {
package/public/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta
6
6
  name="viewport"
7
- content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
7
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
8
8
  />
9
9
  <meta name="apple-mobile-web-app-capable" content="yes" />
10
10
  <meta name="mobile-web-app-capable" content="yes" />
@@ -85,6 +85,28 @@
85
85
  color: var(--text-secondary);
86
86
  margin-top: 4px;
87
87
  }
88
+ .header-btn {
89
+ position: absolute;
90
+ top: 16px;
91
+ background: none;
92
+ border: 1px solid var(--border);
93
+ color: var(--text-dim);
94
+ width: 32px;
95
+ height: 32px;
96
+ border-radius: 8px;
97
+ cursor: pointer;
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ font-size: 16px;
102
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
103
+ -webkit-tap-highlight-color: transparent;
104
+ }
105
+ .header-btn:hover {
106
+ color: var(--text);
107
+ border-color: var(--border-subtle);
108
+ background: var(--border);
109
+ }
88
110
  .theme-toggle {
89
111
  position: absolute;
90
112
  top: 16px;
@@ -111,7 +133,7 @@
111
133
 
112
134
  .sessions-list {
113
135
  padding: 16px;
114
- padding-bottom: 80px;
136
+ padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
115
137
  display: flex;
116
138
  flex-direction: column;
117
139
  gap: 12px;
@@ -235,9 +257,9 @@
235
257
 
236
258
  .new-session {
237
259
  position: fixed;
238
- bottom: 16px;
239
- left: 16px;
240
- right: 16px;
260
+ bottom: calc(16px + env(safe-area-inset-bottom, 0px));
261
+ left: calc(16px + env(safe-area-inset-left, 0px));
262
+ right: calc(16px + env(safe-area-inset-right, 0px));
241
263
  padding: 14px;
242
264
  background: var(--accent);
243
265
  color: #ffffff;
@@ -250,7 +272,6 @@
250
272
  z-index: 50;
251
273
  transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
252
274
  box-shadow: 0 2px 8px rgba(0, 120, 212, 0.3);
253
- padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
254
275
  }
255
276
  .new-session:hover {
256
277
  background: var(--accent-hover);
@@ -563,12 +584,39 @@
563
584
  background: var(--accent-active);
564
585
  transform: scale(0.98);
565
586
  }
587
+
588
+ .color-picker {
589
+ display: flex;
590
+ gap: 8px;
591
+ padding: 6px 0;
592
+ flex-wrap: wrap;
593
+ }
594
+ .color-swatch {
595
+ width: 32px;
596
+ height: 32px;
597
+ border-radius: 50%;
598
+ border: 3px solid transparent;
599
+ cursor: pointer;
600
+ transition: border-color 0.15s, transform 0.1s;
601
+ -webkit-tap-highlight-color: transparent;
602
+ padding: 0;
603
+ outline: none;
604
+ }
605
+ .color-swatch:hover {
606
+ transform: scale(1.1);
607
+ }
608
+ .color-swatch.selected {
609
+ border-color: var(--text);
610
+ transform: scale(1.15);
611
+ }
566
612
  </style>
567
613
  </head>
568
614
  <body>
569
615
  <div class="header">
570
616
  <h1>📡 Term<span>Beam</span></h1>
571
617
  <p>Beam your terminal to any device · <span id="version" style="color: var(--accent)"></span></p>
618
+ <button class="header-btn" id="share-btn" style="right: 96px;top:16px" title="Share link"><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="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg></button>
619
+ <button class="header-btn" id="refresh-btn" style="right: 56px;top:16px" title="Refresh app"><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></button>
572
620
  <button class="theme-toggle" id="theme-toggle" title="Toggle theme"><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="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg></button>
573
621
  </div>
574
622
 
@@ -586,6 +634,17 @@
586
634
  </select>
587
635
  <label for="sess-cmd">Initial Command <span style="color:var(--text-muted);font-weight:normal">(optional)</span></label>
588
636
  <input type="text" id="sess-cmd" placeholder="e.g. copilot, htop, vim" />
637
+ <label>Color</label>
638
+ <div class="color-picker" id="color-picker">
639
+ <button type="button" class="color-swatch selected" data-color="#4a9eff" style="background:#4a9eff" title="Blue"></button>
640
+ <button type="button" class="color-swatch" data-color="#4ade80" style="background:#4ade80" title="Green"></button>
641
+ <button type="button" class="color-swatch" data-color="#fbbf24" style="background:#fbbf24" title="Amber"></button>
642
+ <button type="button" class="color-swatch" data-color="#c084fc" style="background:#c084fc" title="Purple"></button>
643
+ <button type="button" class="color-swatch" data-color="#f87171" style="background:#f87171" title="Red"></button>
644
+ <button type="button" class="color-swatch" data-color="#22d3ee" style="background:#22d3ee" title="Cyan"></button>
645
+ <button type="button" class="color-swatch" data-color="#fb923c" style="background:#fb923c" title="Orange"></button>
646
+ <button type="button" class="color-swatch" data-color="#f472b6" style="background:#f472b6" title="Pink"></button>
647
+ </div>
589
648
  <label for="sess-cwd">Working Directory</label>
590
649
  <div class="cwd-picker">
591
650
  <input type="text" id="sess-cwd" placeholder="/Users/dorlugasigal" />
@@ -666,6 +725,15 @@
666
725
  const listEl = document.getElementById('sessions-list');
667
726
  const modal = document.getElementById('modal');
668
727
 
728
+ function getActivityLabel(ts) {
729
+ if (!ts) return '';
730
+ const diff = (Date.now() - ts) / 1000;
731
+ if (diff < 10) return 'Active now';
732
+ if (diff < 60) return Math.floor(diff) + 's ago';
733
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
734
+ return Math.floor(diff / 3600) + 'h ago';
735
+ }
736
+
669
737
  async function loadSessions() {
670
738
  const res = await fetch('/api/sessions');
671
739
  const sessions = await res.json();
@@ -678,19 +746,20 @@
678
746
  listEl.innerHTML = sessions
679
747
  .map(
680
748
  (s) => `
681
- <div class="swipe-wrap" data-session-id="${s.id}">
749
+ <div class="swipe-wrap" data-session-id="${esc(s.id)}">
682
750
  <div class="swipe-delete">
683
- <button onclick="deleteSession('${s.id}', event)"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
751
+ <button data-delete-id="${esc(s.id)}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
684
752
  </div>
685
- <div class="session-card" onclick="location.href='/terminal?id=${s.id}'">
753
+ <div class="session-card" data-nav-id="${esc(s.id)}">
686
754
  <div class="top">
687
- <div class="name"><span class="dot"></span>${esc(s.name)}</div>
755
+ <div class="name"><span class="dot" data-color="${esc(s.color || '')}"></span>${esc(s.name)}</div>
688
756
  <span class="pid">PID ${s.pid}</span>
689
757
  </div>
690
758
  <div class="details">
691
759
  <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> ${esc(s.cwd)}</span>
692
760
  <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> ${esc(s.shell)}</span>
693
761
  <span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg> ${s.clients} connected</span>
762
+ <span title="Last activity">${getActivityLabel(s.lastActivity)}</span>
694
763
  </div>
695
764
  <button class="connect-btn">Connect →</button>
696
765
  </div>
@@ -699,8 +768,17 @@
699
768
  )
700
769
  .join('');
701
770
 
702
- // Attach swipe handlers after rendering
771
+ // Attach swipe handlers and click handlers after rendering
703
772
  listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
773
+ listEl.querySelectorAll('[data-delete-id]').forEach(btn => {
774
+ btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
775
+ });
776
+ listEl.querySelectorAll('[data-nav-id]').forEach(card => {
777
+ card.addEventListener('click', () => { location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId); });
778
+ });
779
+ listEl.querySelectorAll('.dot[data-color]').forEach(dot => {
780
+ dot.style.background = dot.dataset.color || 'var(--success)';
781
+ });
704
782
  }
705
783
 
706
784
  function esc(str) {
@@ -720,17 +798,28 @@
720
798
  if (e.target === modal) modal.classList.remove('visible');
721
799
  });
722
800
 
801
+ // Color picker
802
+ document.getElementById('color-picker').addEventListener('click', (e) => {
803
+ const swatch = e.target.closest('.color-swatch');
804
+ if (!swatch) return;
805
+ document.querySelectorAll('#color-picker .color-swatch').forEach(s => s.classList.remove('selected'));
806
+ swatch.classList.add('selected');
807
+ });
808
+
723
809
  document.getElementById('modal-create').addEventListener('click', async () => {
724
810
  const name = document.getElementById('sess-name').value.trim();
725
811
  const shell = document.getElementById('sess-shell').value.trim();
726
812
  const cwd = document.getElementById('sess-cwd').value.trim();
727
813
  const initialCommand = document.getElementById('sess-cmd').value.trim();
728
814
 
815
+ const colorEl = document.querySelector('#color-picker .color-swatch.selected');
816
+ const color = colorEl ? colorEl.dataset.color : null;
729
817
  const body = {};
730
818
  if (name) body.name = name;
731
819
  if (shell) body.shell = shell;
732
820
  if (cwd) body.cwd = cwd;
733
821
  if (initialCommand) body.initialCommand = initialCommand;
822
+ if (color) body.color = color;
734
823
 
735
824
  const res = await fetch('/api/sessions', {
736
825
  method: 'POST',
@@ -897,7 +986,7 @@
897
986
  }
898
987
  browserList.innerHTML = data.dirs
899
988
  .map((d) => {
900
- const name = d.split('/').pop();
989
+ const name = d.split(/[/\\]/).pop();
901
990
  return `<div class="folder-item" data-path="${esc(d)}">
902
991
  <span class="folder-icon">📁</span>
903
992
  <span class="folder-name">${esc(name)}</span>
@@ -944,6 +1033,47 @@
944
1033
  loadSessions();
945
1034
  setInterval(loadSessions, 3000);
946
1035
 
1036
+ // Share button
1037
+ function copyToClipboardFallback(text) {
1038
+ const ta = document.createElement('textarea');
1039
+ ta.value = text;
1040
+ ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
1041
+ document.body.appendChild(ta);
1042
+ ta.select();
1043
+ try { document.execCommand('copy'); } catch {}
1044
+ document.body.removeChild(ta);
1045
+ }
1046
+
1047
+ function showShareToast(msg) {
1048
+ const toast = document.createElement('div');
1049
+ toast.textContent = msg;
1050
+ toast.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);background:var(--surface);color:var(--text);border:1px solid var(--border);padding:6px 16px;border-radius:8px;font-size:13px;font-weight:600;z-index:200;';
1051
+ document.body.appendChild(toast);
1052
+ setTimeout(() => toast.remove(), 1500);
1053
+ }
1054
+
1055
+ document.getElementById('share-btn').addEventListener('click', async () => {
1056
+ const url = location.href;
1057
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1058
+ try { await navigator.clipboard.writeText(url); showShareToast('Link copied!'); return; } catch {}
1059
+ }
1060
+ copyToClipboardFallback(url);
1061
+ showShareToast('Link copied!');
1062
+ });
1063
+
1064
+ // Refresh button: clear SW cache and reload
1065
+ document.getElementById('refresh-btn').addEventListener('click', async () => {
1066
+ if ('caches' in window) {
1067
+ const keys = await caches.keys();
1068
+ await Promise.all(keys.map(k => caches.delete(k)));
1069
+ }
1070
+ if (navigator.serviceWorker) {
1071
+ const reg = await navigator.serviceWorker.getRegistration();
1072
+ if (reg) await reg.update();
1073
+ }
1074
+ location.reload();
1075
+ });
1076
+
947
1077
  if ('serviceWorker' in navigator) {
948
1078
  navigator.serviceWorker.register('/sw.js').catch(() => {});
949
1079
  }
package/public/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = 'termbeam-v1';
1
+ const CACHE_NAME = 'termbeam-v2';
2
2
  const SHELL_URLS = ['/', '/terminal'];
3
3
 
4
4
  self.addEventListener('install', (event) => {
@@ -20,16 +20,34 @@ self.addEventListener('activate', (event) => {
20
20
  self.addEventListener('fetch', (event) => {
21
21
  const url = new URL(event.request.url);
22
22
 
23
- // Don't cache WebSocket upgrades or external resources
23
+ // Don't cache WebSocket upgrades
24
24
  if (
25
25
  event.request.mode === 'websocket' ||
26
26
  url.protocol === 'ws:' ||
27
- url.protocol === 'wss:' ||
28
- url.origin !== self.location.origin
27
+ url.protocol === 'wss:'
29
28
  ) {
30
29
  return;
31
30
  }
32
31
 
32
+ // Cache-first for CDN font files (NerdFont from jsdelivr)
33
+ if (url.origin !== self.location.origin) {
34
+ if (url.hostname === 'cdn.jsdelivr.net' && url.pathname.endsWith('.ttf')) {
35
+ event.respondWith(
36
+ caches.match(event.request).then((cached) => {
37
+ if (cached) return cached;
38
+ return fetch(event.request).then((response) => {
39
+ if (response.ok) {
40
+ const clone = response.clone();
41
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
42
+ }
43
+ return response;
44
+ });
45
+ })
46
+ );
47
+ }
48
+ return;
49
+ }
50
+
33
51
  // Network-first for API calls
34
52
  if (url.pathname.startsWith('/api/')) {
35
53
  event.respondWith(
@@ -49,6 +67,6 @@ self.addEventListener('fetch', (event) => {
49
67
  }
50
68
  return response;
51
69
  });
52
- })
70
+ }).catch(() => new Response('Offline', { status: 503, statusText: 'Service Unavailable' }))
53
71
  );
54
72
  });