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

This commit is contained in:
Mortdecai
2026-03-26 08:46:00 -04:00
commit 94eb19aa76
14 changed files with 557 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.
+137
View File
@@ -0,0 +1,137 @@
# kitty-web
Mobile-first web terminal powered by [ttyd](https://github.com/tsl0922/ttyd) + [tmux](https://github.com/tmux/tmux). One persistent tmux session, multiple tabs, accessible from any browser. Designed for phones and tablets.
## Features
- **Single persistent session** — one tmux session shared across all connected clients
- **Mobile touch toolbar** — on-screen buttons for common shortcuts (new tab, ^C, ^D, Esc, arrows, split panes, etc.)
- **Text selection mode** — tap `Sel` to enter selection mode, long-press to select and copy text, tap `Done` to resume typing
- **Push notifications** — send browser notifications from any terminal command
- **PWA installable** — add to home screen for an app-like experience
- **Dark theme** — styled for dark-mode terminals with orange accents
## Architecture
```
Browser -> Caddy (HTTPS + Auth) -> ttyd (port 7681) -> tmux session
-> notify-server (port 7682) -> /api/notifications
```
- **ttyd** serves the terminal UI with a custom index page that loads `toolbar.js`
- **toolbar.js** injects the mobile toolbar (shortcut buttons, selection mode) into the page at runtime
- **notify-server.py** provides a simple HTTP endpoint for push notifications
- **kitty-notify** is a CLI command to trigger notifications from scripts
## Quick Start
```bash
sudo ./install.sh
```
This installs ttyd, tmux, systemd services, and the notification system. The terminal will be available at `http://YOUR_IP:7681`.
### Configuration
Set environment variables before running the installer:
| Variable | Default | Description |
|----------|---------|-------------|
| `KITTY_USER` | `rdp` | System user that owns the tmux session |
| `TTYD_PORT` | `7681` | Port for ttyd web terminal |
| `NOTIFY_PORT` | `7682` | Port for notification API |
| `FONT_SIZE` | `18` | Terminal font size (optimized for mobile) |
### Reverse Proxy
See `caddy-example.conf` for a complete Caddy v2 configuration with authentication. The setup supports OAuth2 Proxy, Authentik, or Authelia for access control.
Key requirements for the reverse proxy:
- WebSocket support (ttyd uses WebSockets for terminal I/O)
- Serve `/toolbar.js`, `/manifest.json`, `/icon-*.png` as static files
- Proxy `/api/*` to the notification server (port 7682)
- Proxy everything else to ttyd (port 7681)
## Mobile Toolbar
The toolbar appears automatically on screens narrower than 900px. Buttons:
| Button | Action | tmux Key |
|--------|--------|----------|
| **+Tab** | New tab | `Ctrl-A c` |
| **Next** | Next tab | `Ctrl-A n` |
| **Prev** | Previous tab | `Ctrl-A p` |
| **^C** | Interrupt | `Ctrl-C` |
| **^D** | EOF / logout | `Ctrl-D` |
| **Clr** | Clear screen | `Ctrl-L` |
| **Esc** | Escape key | `Escape` |
| **Tab** | Tab completion | `Tab` |
| **Up/Down** | History navigation | Arrow keys |
| **Sel** | Toggle text selection mode | — |
| **Spl** | Split pane vertically | `Ctrl-A %` |
| **Pane** | Cycle between panes | `Ctrl-A o` |
| **Kill** | Kill current pane/tab | `Ctrl-A x` |
## Push Notifications
Send notifications from any terminal session:
```bash
# Direct message
kitty-notify "Build complete!"
# Pipe output
echo "Deploy finished" | kitty-notify
# Use in scripts
make build && kitty-notify "Build succeeded" || kitty-notify "Build FAILED"
```
Notifications appear as browser push notifications on mobile. Tap the bell icon in the terminal UI to enable them. Notifications expire after 30 seconds.
## tmux Keybindings
The included tmux config uses `Ctrl-A` as the prefix (easier on mobile than the default `Ctrl-B`):
| Key | Action |
|-----|--------|
| `Ctrl-A c` | New window/tab |
| `Ctrl-A n` / `Ctrl-A p` | Next / previous window |
| `Ctrl-A %` / `Ctrl-A "` | Split vertical / horizontal |
| `Ctrl-A o` | Cycle panes |
| `Ctrl-A x` | Kill pane |
| `Alt-1` through `Alt-5` | Jump to window 1-5 |
| `Alt-Left` / `Alt-Right` | Previous / next window |
| `Alt-t` | New window |
| Mouse scroll | Scroll through history |
## Files
```
kitty-web/
toolbar.js # Mobile toolbar (injected into ttyd page)
notify-server.py # Push notification HTTP API
kitty-notify # CLI notification command
manifest.json # PWA manifest
icon-192.png # PWA icon (192x192)
icon-512.png # PWA icon (512x512)
install.sh # Installer script
caddy-example.conf # Reverse proxy configuration example
config/
tmux.conf # tmux configuration (dark theme, mobile-friendly)
systemd/
ttyd-kitty.service # ttyd systemd unit
kitty-notify.service # Notification API systemd unit
```
## Requirements
- Linux (Debian/Ubuntu tested)
- tmux
- Python 3 (for notification server)
- [ttyd](https://github.com/tsl0922/ttyd) (installed automatically)
- Caddy, nginx, or another reverse proxy (for HTTPS and auth)
## License
MIT
+68
View File
@@ -0,0 +1,68 @@
# Example Caddy reverse proxy config for kitty-web
#
# Prerequisites:
# - Caddy v2 with HTTPS
# - OAuth2 Proxy, Authentik, or Authelia for authentication (recommended)
# - ttyd running on TTYD_HOST:7681
# - notify server running on TTYD_HOST:7682
#
# Replace TTYD_HOST with the IP/hostname of the machine running kitty-web.
# Replace the auth snippet with your own authentication setup.
# --- Authentication snippet (pick one) ---
# Option A: OAuth2 Proxy (Google Auth)
# (google_auth) {
# forward_auth OAUTH2_PROXY_HOST:4180 {
# uri /oauth2/auth
# header_up Host auth.example.com
# copy_headers X-Auth-Request-User X-Auth-Request-Email
# handle_response @unauthorized {
# redir https://auth.example.com/oauth2/start?rd={scheme}://{host}{uri}
# }
# @unauthorized status 401
# }
# }
# Option B: Authentik
# (authentik) {
# forward_auth AUTHENTIK_HOST:9000 {
# uri /outpost.goauthentik.io/auth/caddy
# copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
# }
# }
# --- Site block ---
# terminal.example.com {
# import google_auth
#
# # Undo any cached permanent redirects from old configs
# @oldttyd path /ttyd /ttyd/
# redir @oldttyd / temporary
#
# # Static assets (toolbar, PWA manifest, icons)
# handle /toolbar.js {
# root * /opt/kitty-web/static
# file_server
# }
# handle /manifest.json {
# root * /opt/kitty-web/static
# file_server
# }
# handle /icon-*.png {
# root * /opt/kitty-web/static
# file_server
# }
#
# # Notification API
# handle /api/* {
# uri strip_prefix /api
# reverse_proxy TTYD_HOST:7682
# }
#
# # Terminal (catch-all)
# handle {
# reverse_proxy TTYD_HOST:7681
# }
# }
+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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Executable
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# kitty-web installer
# Mobile-first web terminal with tmux, ttyd, and push notifications
set -e
TTYD_VERSION="1.7.7"
SERVICE_USER="${KITTY_USER:-rdp}"
TTYD_PORT="${TTYD_PORT:-7681}"
NOTIFY_PORT="${NOTIFY_PORT:-7682}"
FONT_SIZE="${FONT_SIZE:-18}"
echo "=== kitty-web installer ==="
echo "Service user: $SERVICE_USER"
echo "ttyd port: $TTYD_PORT"
echo "Notify port: $NOTIFY_PORT"
# Check root
if [ "$EUID" -ne 0 ]; then
echo "Run as root"
exit 1
fi
# Install dependencies
echo "[1/6] Installing dependencies..."
apt install -y tmux 2>/dev/null || true
# Install ttyd
if ! command -v ttyd &>/dev/null; then
echo "[2/6] Installing ttyd $TTYD_VERSION..."
curl -sL "https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.x86_64" -o /usr/local/bin/ttyd
chmod +x /usr/local/bin/ttyd
else
echo "[2/6] ttyd already installed"
fi
# Create user if needed
if ! id "$SERVICE_USER" &>/dev/null; then
echo "[3/6] Creating user $SERVICE_USER..."
useradd -m -s /bin/bash "$SERVICE_USER"
else
echo "[3/6] User $SERVICE_USER exists"
fi
# Deploy files
echo "[4/6] Deploying files..."
mkdir -p /opt/kitty-web/static
# Build custom index (inject toolbar into ttyd's default page)
TMPINDEX=$(mktemp)
ttyd --port 0 /bin/true &
TTYD_PID=$!
sleep 1
# Can't easily grab default page without a running instance, so we'll
# add toolbar.js loading to the page at runtime via the notify server
kill $TTYD_PID 2>/dev/null || true
cp "$(dirname "$0")/toolbar.js" /opt/kitty-web/static/toolbar.js
cp "$(dirname "$0")/manifest.json" /opt/kitty-web/static/manifest.json
cp "$(dirname "$0")/icon-192.png" /opt/kitty-web/static/icon-192.png 2>/dev/null || true
cp "$(dirname "$0")/icon-512.png" /opt/kitty-web/static/icon-512.png 2>/dev/null || true
cp "$(dirname "$0")/notify-server.py" /opt/kitty-web/notify-server.py
chmod +x /opt/kitty-web/notify-server.py
# Install kitty-notify command
cp "$(dirname "$0")/kitty-notify" /usr/local/bin/kitty-notify
chmod +x /usr/local/bin/kitty-notify
# Install tmux config
sudo -u "$SERVICE_USER" cp "$(dirname "$0")/config/tmux.conf" "$(eval echo ~$SERVICE_USER)/.tmux.conf" 2>/dev/null || true
# Install systemd services
echo "[5/6] Installing services..."
sed "s/User=rdp/User=$SERVICE_USER/g; s/Group=rdp/Group=$SERVICE_USER/g; s/--port 7681/--port $TTYD_PORT/g; s/fontSize=18/fontSize=$FONT_SIZE/g" \
"$(dirname "$0")/systemd/ttyd-kitty.service" > /etc/systemd/system/ttyd-kitty.service
sed "s/User=rdp/User=$SERVICE_USER/g" \
"$(dirname "$0")/systemd/kitty-notify.service" > /etc/systemd/system/kitty-notify.service
systemctl daemon-reload
systemctl enable --now ttyd-kitty kitty-notify
echo "[6/6] Verifying..."
sleep 2
systemctl is-active ttyd-kitty && echo " ttyd: OK (port $TTYD_PORT)"
systemctl is-active kitty-notify && echo " notify: OK (port $NOTIFY_PORT)"
echo ""
echo "=== kitty-web is running ==="
echo "Direct access: http://$(hostname -I | awk '{print $1}'):$TTYD_PORT"
echo ""
echo "For reverse proxy setup, see README.md"
echo "Send notifications: kitty-notify 'Hello from the terminal!'"
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# Send a push notification to kitty.sethpc.xyz
# Usage: kitty-notify "Build complete!" or echo "done" | kitty-notify
if [ -n "$1" ]; then
echo "$*" > /tmp/kitty-notify
else
cat > /tmp/kitty-notify
fi
+12
View File
@@ -0,0 +1,12 @@
{
"name": "sethpc terminal",
"short_name": "kitty",
"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"}
]
}
+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()
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Kitty terminal notification API
After=network.target
[Service]
Type=simple
User=rdp
ExecStart=/usr/bin/python3 /opt/ttyd/notify-server.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=ttyd web terminal (kitty.sethpc.xyz)
After=network.target
[Service]
Type=simple
User=rdp
Group=rdp
ExecStartPre=/bin/bash -c "/usr/bin/tmux kill-session -t kitty 2>/dev/null; sleep 0.5; /usr/bin/tmux new-session -d -s kitty -x 120 -y 40"
ExecStart=/usr/local/bin/ttyd \
--port 7681 \
--interface 0.0.0.0 \
--index /opt/ttyd/index-with-toolbar.html \
--writable \
--check-origin \
--max-clients 5 \
--ping-interval 30 \
--client-option titleFixed=sethpc.xyz \
--client-option fontSize=18 \
--client-option fontFamily=monospace \
--client-option enableSixel=true \
/usr/bin/tmux attach-session -t kitty
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+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});
})();