feat: initial release - mobile-first web terminal with tmux touch toolbar and push notifications

This commit is contained in:
Mortdecai
2026-03-26 18:59:37 -04:00
commit 5f1beb4d4d
13 changed files with 376 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
*.env
.env*
*.pem
*.key
__pycache__/
*.pyc
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Seth Freiberg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+113
View File
@@ -0,0 +1,113 @@
# sethmux
Mobile-first web terminal powered by [ttyd](https://github.com/tsl0922/ttyd) + [tmux](https://github.com/tmux/tmux). One persistent session, multiple tabs, accessible from any browser.
## Features
- **Single persistent tmux session** — shared across all connected clients
- **Mobile touch toolbar** — on-screen buttons for tabs, signals, navigation, splits, and text selection
- **Text selection mode** — tap `Sel` to select and copy text on touch devices
- **Push notifications** — `sethmux-notify "Build done!"` sends browser notifications
- **PWA installable** — add to home screen for app-like experience
- **Dark theme** — Sethian dark + orange (#D35400) accent
## Architecture
```
Browser -> Caddy (HTTPS + Auth) -> ttyd (port 7683) -> tmux session "sethmux"
-> notify-server (port 7684) -> /api/notifications
```
## Quick Start
```bash
# Dependencies
apt install -y tmux
curl -sL https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64 -o /usr/local/bin/ttyd
chmod +x /usr/local/bin/ttyd
# Deploy
sudo mkdir -p /opt/sethmux
sudo cp static/* /opt/sethmux/
sudo cp notify-server.py /opt/sethmux/
sudo cp sethmux-notify /usr/local/bin/
sudo cp config/tmux.conf ~/.tmux.conf
sudo cp systemd/*.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now sethmux sethmux-notify
```
Open `http://YOUR_IP:7683` in a browser.
## Mobile Toolbar
Appears on screens < 900px. Buttons:
| Button | Action | tmux Key |
|--------|--------|----------|
| **+Tab** | New tab | `Ctrl-A c` |
| **Next/Prev** | Switch tabs | `Ctrl-A n/p` |
| **^C / ^D / Clr** | Interrupt / EOF / Clear | |
| **Esc / Tab / Up / Down** | Navigation | |
| **Sel** | Text selection mode | |
| **Spl** | Split vertical | `Ctrl-A %` |
| **Pane** | Cycle panes | `Ctrl-A o` |
| **Kill** | Kill pane/tab | `Ctrl-A x` |
## Push Notifications
```bash
sethmux-notify "Deploy complete!"
echo "done" | sethmux-notify
make build && sethmux-notify "OK" || sethmux-notify "FAIL"
```
## tmux Keybindings
Prefix: `Ctrl-A` (not the default Ctrl-B — easier on mobile).
| Key | Action |
|-----|--------|
| `Ctrl-A c` | New window |
| `Ctrl-A n / p` | Next / previous |
| `Ctrl-A % / "` | Split vertical / horizontal |
| `Ctrl-A o` | Cycle panes |
| `Ctrl-A x` | Kill pane |
| `Alt-1` to `Alt-5` | Jump to window |
| Mouse scroll | History |
## Reverse Proxy (Caddy)
```
mux.example.com {
# your auth here
handle /toolbar.js { root * /opt/sethmux; file_server }
handle /manifest.json { root * /opt/sethmux; file_server }
handle /icon-*.png { root * /opt/sethmux; file_server }
handle /api/* { uri strip_prefix /api; reverse_proxy localhost:7684 }
handle { reverse_proxy localhost:7683 }
}
```
## Files
```
sethmux/
static/
index.html # Custom ttyd page with toolbar injection
toolbar.js # Mobile touch toolbar
manifest.json # PWA manifest
icon-192.png # PWA icon
icon-512.png # PWA icon
config/
tmux.conf # Sethian-themed tmux config
systemd/
sethmux.service # ttyd + tmux systemd unit
sethmux-notify.service # Notification API unit
notify-server.py # Push notification HTTP API
sethmux-notify # CLI notification command
```
## License
MIT
+53
View File
@@ -0,0 +1,53 @@
# Sethian tmux config
# Remap prefix to Ctrl-a (easier on mobile)
unbind C-b
set -g prefix C-a
bind C-a send-prefix
# Easy tab management
bind -n M-t new-window
bind -n M-w kill-window
bind -n M-1 select-window -t 0
bind -n M-2 select-window -t 1
bind -n M-3 select-window -t 2
bind -n M-4 select-window -t 3
bind -n M-5 select-window -t 4
bind -n M-Left previous-window
bind -n M-Right next-window
# Mouse support (critical for mobile/touch)
set -g mouse on
# Scrollback
set -g history-limit 50000
# Start numbering at 1
set -g base-index 1
setw -g pane-base-index 1
# Renumber windows on close
set -g renumber-windows on
# Status bar - Sethian dark + orange
set -g status-style "bg=#1a1a1a,fg=#e0e0e0"
set -g status-left "#[bg=#D35400,fg=#0a0a0a,bold] #S #[bg=#1a1a1a] "
set -g status-right "#[fg=#D35400]%H:%M #[fg=#666666]| #[fg=#e0e0e0]%b %d"
set -g status-left-length 20
set -g status-right-length 30
# Window status
setw -g window-status-format " #[fg=#888888]#I:#W "
setw -g window-status-current-format "#[bg=#D35400,fg=#0a0a0a,bold] #I:#W "
setw -g window-status-separator ""
# Pane borders
set -g pane-border-style "fg=#333333"
set -g pane-active-border-style "fg=#D35400"
# Terminal settings
set -g default-terminal "tmux-256color"
set -ga terminal-overrides ",xterm-256color:Tc"
# Reduce escape delay
set -sg escape-time 10
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""Tiny HTTP server for terminal notifications. Serves /api/notifications."""
import http.server
import json
import os
import time
NOTIFY_FILE = "/tmp/kitty-notify"
PORT = 7682
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/api/notifications":
msg = ""
if os.path.exists(NOTIFY_FILE):
try:
mtime = os.path.getmtime(NOTIFY_FILE)
if time.time() - mtime < 30: # only show notifications < 30s old
with open(NOTIFY_FILE) as f:
msg = f.read().strip()
except:
pass
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps({"message": msg}).encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # quiet
if __name__ == "__main__":
server = http.server.HTTPServer(("0.0.0.0", PORT), Handler)
server.serve_forever()
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Send a push notification to kitty.sethpc.xyz
# Usage: sethmux-notify "Build complete!" or echo "done" | sethmux-notify
if [ -n "$1" ]; then
echo "$*" > /tmp/sethmux-notify
else
cat > /tmp/sethmux-notify
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
{
"name": "sethmux",
"short_name": "sethmux",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#D35400",
"icons": [
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"}
]
}
+81
View File
@@ -0,0 +1,81 @@
(function(){
if(window._toolbar) return;
window._toolbar=true;
var css=document.createElement('style');
css.textContent=`
#mb{display:none;position:fixed;bottom:0;left:0;right:0;background:#111;
border-top:2px solid #D35400;padding:5px 4px;gap:4px;justify-content:center;
flex-wrap:wrap;z-index:99999}
#mb button{background:#222;color:#ccc;border:1px solid #444;border-radius:5px;
padding:10px 12px;font-size:14px;font-family:ui-monospace,monospace;
cursor:pointer;touch-action:manipulation;-webkit-tap-highlight-color:transparent;
min-width:42px;text-align:center;user-select:none}
#mb button:active{background:#D35400;color:#0a0a0a;border-color:#D35400}
#mb button.hi{border-color:#D35400;color:#D35400}
#mb button.on{background:#D35400;color:#0a0a0a;border-color:#D35400}
#mb .sep{width:1px;background:#333;margin:0 2px;align-self:stretch}
@media(max-width:900px){#mb{display:flex}}
body.selmode .xterm-screen{pointer-events:none!important;
user-select:text!important;-webkit-user-select:text!important}
`;
document.head.appendChild(css);
var bar=document.createElement('div');
bar.id='mb';
bar.innerHTML=
'<button class="hi" data-k="\\x01c">+Tab</button>'+
'<button data-k="\\x01n">Next</button>'+
'<button data-k="\\x01p">Prev</button>'+
'<div class="sep"></div>'+
'<button data-k="\\x03">^C</button>'+
'<button data-k="\\x04">^D</button>'+
'<button data-k="\\x0c">Clr</button>'+
'<div class="sep"></div>'+
'<button data-k="\\x1b">Esc</button>'+
'<button data-k="\\t">Tab</button>'+
'<button data-k="\\x1bOA">\u25B2</button>'+
'<button data-k="\\x1bOB">\u25BC</button>'+
'<div class="sep"></div>'+
'<button class="hi" id="selbtn" data-sel="1">Sel</button>'+
'<button data-k="\\x01%">Spl</button>'+
'<button data-k="\\x01o">Pane</button>'+
'<button data-k="\\x01x">Kill</button>';
document.body.appendChild(bar);
function send(k){
if(document.body.classList.contains('selmode')) toggleSel();
k=k.replace(/\\x([0-9a-f]{2})/gi,function(_,h){return String.fromCharCode(parseInt(h,16));});
k=k.replace(/\\t/g,'\t');
if(window.term){window.term.input(k);window.term.focus();}
}
function toggleSel(){
var b=document.getElementById('selbtn');
document.body.classList.toggle('selmode');
if(document.body.classList.contains('selmode')){
b.classList.add('on');b.textContent='Done';
} else {
b.classList.remove('on');b.textContent='Sel';
window.getSelection().removeAllRanges();
if(window.term) window.term.focus();
}
}
bar.addEventListener('click',function(e){
var btn=e.target.closest('button');
if(!btn) return;
if(btn.dataset.sel) return toggleSel();
if(btn.dataset.k) send(btn.dataset.k);
});
// Shrink terminal for toolbar on mobile
var obs=new MutationObserver(function(){
var el=document.querySelector('.xterm');
if(el && window.innerWidth<=900){
el.style.height='calc(100vh - 54px)';
if(window.term && window.term.fit) window.term.fit();
}
});
obs.observe(document.body,{childList:true,subtree:true});
})();
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=sethmux notification API
After=network.target
[Service]
Type=simple
User=rdp
ExecStart=/usr/bin/python3 /opt/sethmux/notify-server.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=sethmux - web terminal (mux.sethpc.xyz)
After=network.target
[Service]
Type=simple
User=rdp
Group=rdp
ExecStartPre=/bin/bash -c "/usr/bin/tmux kill-session -t sethmux 2>/dev/null; sleep 0.5; /usr/bin/tmux new-session -d -s sethmux -x 120 -y 40"
ExecStart=/usr/local/bin/ttyd \
--port 7683 \
--interface 0.0.0.0 \
--index /opt/sethmux/index.html \
--writable \
--check-origin \
--max-clients 5 \
--ping-interval 30 \
--client-option titleFixed=sethmux \
--client-option fontSize=18 \
--client-option fontFamily=monospace \
--client-option enableSixel=true \
/usr/bin/tmux attach-session -t sethmux
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target