diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 8d3a554..0000000 --- a/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -node_modules/ -package-lock.json -.DS_Store -docs/prompt.txt -certs/ -*.crt -*.key -*.pem -data/ -!data/shortcuts.sample.json -public/shortcuts.json -public/icons/ diff --git a/README.md b/README.md deleted file mode 100644 index 45445cf..0000000 --- a/README.md +++ /dev/null @@ -1,550 +0,0 @@ -

- TUI Browser -

- -

TUI Browser

- -

- VNC for terminals — not another SSH web client.
- Mirrors your actual desktop terminal to any browser in real-time. -

- -

- Features · - Quick Start · - Security · - How It Works · - Gotchas & Tips -

- ---- - -Access and control your terminal sessions from any browser — phone, tablet, or another computer. The browser and host terminal stay perfectly in sync, both viewing and controlling the same tmux session. Unlike SSH tools that spawn isolated shells, this mirrors your actual desktop terminal in real-time. - -Built for TUI-heavy workflows (Claude Code, OpenCode, Codex, htop, etc.) where you want to start something on your desktop and check on it from your phone. Supports multiple machines from a single dashboard. - -

- Session dashboard -
Session dashboard — AI-generated titles, status indicators, quick actions -

- -## Features - -### Core - -- **VNC-style mirroring** — browser and Kitty terminal show the exact same content. Type in either, both update. -- **Session management** — create, connect, kill, rename sessions from the browser. New sessions also open a Kitty window on the host. -- **Multi-client** — multiple browsers can connect to the same session simultaneously. -- **AI session titles** — on-demand AI title generation via the title button in terminal view (uses Claude CLI haiku model). -- **Claude Code detection** — auto-detects Claude Code sessions and shows a Claude icon button when remote-control is active. Click to copy the URL, double-click to open it. -- **Text input panel** — compose text and send it to the terminal in one shot (like paste), avoids mobile keystroke drops. Pen icon in the quick-keys bar. -- **File browser** — per-server file manager accessible from each server group and terminal view. Browse, view, edit, upload, and download files. Opens to the terminal session's working directory. -- **Multi-machine federation** — connect multiple computers to one dashboard. Each machine runs its own tui-browser server; the client connects directly to each. Sessions are grouped by server with collapsible sections. -- **tui.json session overrides** — drop a `tui.json` in a project directory to customize sessions launched from there: custom titles, action buttons (URL links, file openers), and file browser CWD overrides. Auto-discovered, no API calls needed. See [tui.json format](#tuijson-overrides). -- **Mobile-optimized** — quick-keys bar (toggleable on all screen sizes), scroll controls, text selection overlay, keyboard-aware viewport. - -### Dashboard & Session Tools - -- **Unified dashboard** — tmux sessions grouped by server, enriched with Kitty metadata (tab title, focus state, viewer count). Collapsible server sections with state persisted in browser. -- **Server settings panel** — add/remove servers via the wrench icon. Enter Tailscale IPs or MagicDNS hostnames. -- **Quick Launch** — preset and custom commands saved to `shortcuts.json`, launch sessions in one tap. -- **Bulk session kill** — select multiple sessions to kill at once, or use filter presets: detached, idle, no running commands, or all. -- **Session info panel** — live-updating stats: memory, CPU, process tree, uptime, recent terminal output. -- **Session locking** — lock sessions to prevent accidental kills. Locked sessions disable the kill button. -- **Session sorting** — sort by newest, oldest, recently active, or least active. -- **Font size controls** — zoom in/out on the terminal view with +/- buttons. -- **Open on PC** — relaunch dangling sessions into a Kitty window from the dashboard. - -### File Browser - -- **Session-aware** — opens to the working directory of the current terminal session, or home directory from the dashboard. -- **Browse & navigate** — Google Files-style UI with breadcrumb path, folder-first sorting, vscode-icons for 1,480+ file types. -- **View & edit files** — CodeMirror 6 editor with syntax highlighting for 13 languages, read-only by default, tap Edit to modify and save. -- **Upload files** — drag-and-drop (or file picker on mobile) via FilePond, with progress bars and multi-file support. -- **Download files & folders** — single files download directly, folders download as zip archives. -- **File management** — create folders, rename, delete, copy, move. Long-press for context menu, single-tap to select for bulk actions. -- **Configurable access** — allowed directories set in `data/file-browser-config.json` (defaults to home directory). Path traversal prevention on all endpoints. - -### Under the Hood - -- **Tailscale network isolation** — binds exclusively to the Tailscale interface. Unreachable from public internet or local LAN. -- **Auto-update** — remote servers auto-pull from git and restart when the primary server's version bumps. Pre-commit hook auto-bumps patch version on every commit. -- **Auto-discovery** — PID matching links Kitty windows to their tmux sessions automatically. -- **60fps TUI rendering** — tmux + xterm.js WebGL handles high-frequency output (Claude Code, Ratatui apps, etc.) -- **PWA with auto-update** — installable app, polls server version, auto-reloads on code changes. -- **Cache-first rendering** — sessions load instantly from cache, no flash on page load or phone wake. -- **Online/offline detection** — toast notifications for connectivity changes. -- **Auto-restart** — systemd service with file watcher restarts the server on code changes. -- **Zero build frontend** — vanilla JS, xterm.js/FilePond from CDN, CodeMirror 6 + vscode-icons pre-bundled in `public/vendor/`. - - - - - - -
- Terminal view -
Terminal view — xterm.js with WebGL, quick-keys, scroll controls -
- Session info panel -
Session info — live memory, CPU, process tree, recent output -
- -## Quick Start - -```bash -# Primary machine (serves the dashboard) -./install.sh --server-name desktop --primary - -# Additional machines -./install.sh --server-name laptop -``` - -The install script handles: -- npm dependencies -- `~/.local/bin/tmux-kitty-shell` wrapper (launches Kitty windows inside tmux) -- `~/.tmux.conf` (terminal capabilities, UTF-8, passthrough for TUI apps) -- systemd user service (auto-start on boot, even before login) -- systemd file watcher (auto-restart on code changes) -- Server identity (`data/identity.json`) — names the server for the dashboard -- Pre-commit hook for auto version bumping - -After install, the dashboard is at `http://:7483`. Add additional servers via the wrench icon in the dashboard header. - -### Service Management - -```bash -systemctl --user start tui-browser -systemctl --user stop tui-browser -systemctl --user restart tui-browser -systemctl --user status tui-browser -journalctl --user -u tui-browser -f # tail logs -``` - -### Manual Start (without systemd) - -```bash -npm install -PORT=7483 npm start -``` - -### Prerequisites - -- **Node.js** >= 18 -- **tmux** >= 3.2 (for `allow-passthrough`) -- **Tailscale** — required for network access ([install](https://tailscale.com/download)) -- **Kitty** (optional — for host terminal integration) -- **Claude CLI** (optional — for AI session title generation) - -### Kitty Setup (optional) - -Add to `~/.config/kitty/kitty.conf`: - -``` -allow_remote_control yes -listen_on unix:/tmp/kitty-socket -shell /path/to/your/.local/bin/tmux-kitty-shell -``` - -Restart Kitty. Every new window will launch inside tmux, and the dashboard will show them with Kitty badges. - -### tui.json Overrides - -Drop a `tui.json` file in any project directory to customize sessions launched from there. TUI Browser auto-discovers the file by checking each session's working directory during polling — no API calls or registration needed. - -```json -{ - "my-project-id": { - "title": "Custom Title", - "fileCwd": "/path/to/project/working/dir", - "sessions": ["session-name-abc1", "session-name-xyz9"], - "actions": [ - { "id": "docs", "label": "Docs", "icon": "drupal", "type": "url", "url": "https://example.com" }, - { "id": "notes", "label": "Notes", "icon": "comment", "type": "file-open", "path": "/path/to/notes.md" } - ] - } -} -``` - -**Fields per entry:** -- `title` — overrides the session's display title (manual UI rename still takes precedence) -- `fileCwd` — overrides the file browser's default directory for this session -- `sessions` — array of tmux session names this entry applies to (the key can be any stable identifier) -- `actions` — buttons shown in the terminal toolbar: - - `type: "url"` — opens the URL in a new browser tab - - `type: "file-open"` — opens the file directly in the CodeMirror editor - - `icon` — optional, renders an SVG icon instead of text label. Built-in: `drupal`, `comment` - -**How it works:** Scripts that launch sessions (e.g., via Quick Launch) write/update `tui.json` before starting. The server records each session's origin CWD at creation time and checks for `tui.json` there during every discovery cycle (cached by mtime). Later, other tools (like Claude Code skills) can update the same file to add actions — changes appear within 3 seconds. - -## Security - -**This tool gives full shell access and filesystem access from a browser.** Do not expose it to the public internet or untrusted networks without understanding the risks. Use a VPN like Tailscale to restrict access to trusted devices only. - -TUI Browser binds exclusively to the Tailscale network interface. It is unreachable from the public internet or local LAN — only devices on your Tailscale network can connect. - -**Defense layers:** -- **Network isolation** — server binds to Tailscale IP only (`BIND` env var) -- **WireGuard encryption** — all traffic encrypted end-to-end by Tailscale -- **Device authentication** — only devices you approve on your Tailscale account can reach the server -- **No exposed ports** — nothing listens on public or LAN interfaces - -**Recommended firewall rules** (defense in depth): -```bash -# Block tui-browser ports on all non-Tailscale interfaces -sudo ufw deny 7483 -sudo ufw deny 7484 -``` - -
-

Network Access using Tailscale (recommended)

- -TUI Browser requires [Tailscale](https://tailscale.com/) for network access. Tailscale creates an encrypted mesh VPN — the server binds exclusively to its Tailscale IP and is invisible to the public internet and local LAN. - -**Setup:** - -1. Install Tailscale on all machines: https://tailscale.com/download -2. Run `tailscale up` and authenticate -3. Run `./install.sh` — it auto-detects the Tailscale IP and binds to it - -**Access from any device:** -- Install the Tailscale app (Android, iOS, macOS, Windows, Linux) -- Join the same Tailscale network -- Open `http://:7483` in your browser - -**MagicDNS:** Tailscale assigns each machine a hostname like `machine-name.tailnet-name.ts.net`. Use these instead of raw IPs. - -**Custom domain (optional):** Point a DNS A record to the Tailscale IP (e.g., `tui.yourdomain.com` → `100.x.x.x`). Set the record to **DNS only** (not proxied) in your DNS provider. The domain resolves globally but only Tailscale devices can connect. - -#### Additional Machine Setup - -After running `./install.sh` on a new machine, a few extra steps may be needed: - -**File browser icons:** The vscode-icons SVGs are gitignored and generated locally. If the file browser shows no icons, regenerate with `bash scripts/bundle-vscode-icons.sh` (requires npm in PATH). - -**Node.js via nvm:** If using nvm instead of Volta or system Node, ensure the systemd unit can find Node. Check that `ExecStart` in `~/.config/systemd/user/tui-browser.service` points to the correct Node binary and `PATH` includes your nvm bin directory. - -
- -## How It Works - -``` -Phone/Tablet/Laptop Browser Machine A (primary) Machine B (remote) -┌──────────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ -│ Dashboard │ │ Node.js Server │ │ Node.js Server │ -│ ┌─ HOST ──────────────┐ │ HTTP │ ├── REST API │ │ ├── REST API │ -│ │ Sessions from A │ │◄══════════►│ ├── WebSocket │ │ ├── WebSocket │ -│ └─────────────────────┘ │ Tailscale │ ├── tmux discovery │ │ ├── tmux discovery │ -│ ┌─ LAPTOP ────────────┐ │ WireGuard │ ├── serves frontend │ │ ├── /api/identity │ -│ │ Sessions from B │ │ encrypted │ ├── /api/servers │ │ └── /api/update │ -│ └─────────────────────┘ │◄══════╦═══►│ └── session-manager │ └──────────────────────┘ -│ Terminal View │ ║ └──────────────────────┘ │ -│ ┌─────────────────────┐ │ ║ │ ▼ -│ │ xterm.js — direct │ │ ╚══════════════════════════► ┌────────────────────┐ -│ │ WireGuard to B │ │ ▼ │ tmux sessions │ -│ └─────────────────────┘ │ ┌────────────────────┐ └────────────────────┘ -└──────────────────────────┘ │ tmux sessions │ - └────────────────────┘ -``` - -1. **Every Kitty window runs inside tmux** via a wrapper script (`tmux-kitty-shell`) -2. **PID matching** links Kitty windows to tmux sessions (`kitty_window.pid == tmux_client.client_pid`) -3. **Browser connects** to the same tmux session via node-pty + WebSocket -4. **Both viewers** (Kitty + browser) see identical output — tmux handles multi-client sync natively -5. **Creating a session** from the browser also opens a Kitty window on the host -6. **Killing a session** from the browser closes the Kitty window automatically - -### Multi-Machine Federation - -Each machine runs its own independent tui-browser server. The primary server hosts the frontend SPA. The client (browser) connects directly to each server via Tailscale — no proxy or relay. - -- **HOST** group always shows the primary server's sessions -- Additional servers are added via the settings panel (wrench icon) -- The client connects directly via Tailscale using the configured IP or MagicDNS hostname -- Terminal WebSocket connects directly to the session's origin server -- Version sync: when the primary's `package.json` version bumps, remote servers auto-pull and restart - -#### Setting Up a New Machine - -1. **Install tui-browser** on the new machine: - ```bash - git clone git@github.com:AJV009/tui-browser.git - cd tui-browser - ./install.sh --server-name # use --primary on the main machine - ``` - -2. **Install Tailscale** and join the same network: - ```bash - # Install: https://tailscale.com/download - tailscale up - ``` - -3. **Add the server** in the primary's dashboard via the wrench icon, using the machine's Tailscale IP or MagicDNS hostname. - -
-

Why Not Just SSH?

- -Every tool in this space either **creates new sessions** or **requires you to go through it**. TUI Browser does neither — it discovers your running tmux sessions and gives you a web view into them. The terminal sessions are the source of truth; the web layer is a lens, not a replacement. - -**The core difference is mirroring vs. remoting:** - -| | SSH web clients | TUI Browser | -|---|---|---| -| **What you see** | A new, separate shell | Your actual desktop terminal | -| **Session relationship** | Independent — browser and desktop are different sessions | Shared — browser and desktop are the same session | -| **Start a build on desktop, check from phone** | Can't — phone has its own shell | Yes — phone sees exactly what your desktop shows | -| **Multiple viewers** | Each gets their own session | All see the same output, type into the same session | -| **Session persistence** | Dies when browser tab closes | tmux session persists forever — reconnect anytime | -| **TUI rendering (60fps)** | Varies — often broken through SSH layers | Native — raw PTY via node-pty + WebGL xterm.js | - -**Why WebSocket instead of SSH for transport?** SSH multiplexes its own channels and requires key/password auth on every connection — overhead that adds nothing when the server and terminal are on the same machine. WebSocket gives us raw bidirectional binary streaming over HTTP with custom input batching (30ms buffer), JSON control messages for resize/attach/detach, and reconnection logic that SSH can't express. The browser connects to a local node-pty process that attaches to tmux — there's no remote host to SSH into. - -
-Alternatives comparison — how every major tool in this space differs - -### Web-Based Terminal Servers - -Tools that expose a terminal command over HTTP. The closest category to TUI Browser architecturally, but none discover or attach to existing sessions. - -| Tool | Stack | What it does | Attach existing tmux? | Multi-client? | -|------|-------|-------------|----------------------|---------------| -| **[ttyd](https://github.com/tsl0922/ttyd)** | C, libwebsockets, xterm.js | Exposes one command per port via WebSocket. ~11k stars, actively maintained. | Only via `ttyd tmux new -A -s name` — no discovery, no dashboard, one session per instance. | Via tmux sharing only. | -| **[GoTTY](https://github.com/sorenisanerd/gotty)** | Go | Same concept as ttyd. Original repo unmaintained; maintained fork by sorenisanerd. | Same indirect approach as ttyd. | Via tmux sharing only. | -| **[Zellij](https://github.com/zellij-org/zellij)** | Rust | Terminal multiplexer with a built-in web client (v0.44+). Sessions map to URLs, multi-client support, session resurrection. | Attaches to Zellij sessions, not tmux — requires replacing your multiplexer entirely. | Yes, native. | - -**ttyd** is the closest lightweight alternative — but it's a "run one command" tool. You'd need to spawn/kill ttyd instances dynamically and build a discovery layer on top, which is essentially what TUI Browser already does with node-pty. Swapping node-pty for ttyd adds a process boundary without gaining anything. - -**Zellij** is the most architecturally similar — web access, session URLs, multi-client — but it replaces tmux rather than wrapping it, and adds significant in-terminal UI chrome and latency. - -### Browser-Based SSH Proxies - -These put an SSH client in the browser by proxying through a server: `Browser → WebSocket → server-side SSH client → sshd → shell`. Each browser tab gets its own SSH session — no sharing. - -| Tool | Stack | Notes | -|------|-------|-------| -| **[WeTTy](https://github.com/butlerx/wetty)** | Node.js/TypeScript | ~5k stars. Web wrapper around SSH. Can technically `tmux attach` inside the SSH session, but each tab is independent. | -| **[WebSSH2](https://github.com/billchurch/webssh2)** | Node.js/TypeScript | ~2.7k stars. SSH via Socket.io + ssh2 library. Host key verification (TOFU). | -| **[Sshwifty](https://github.com/nirui/sshwifty)** | Go | ~3k stars. SSH + Telnet client. Single binary. Connection presets. | - -All of these add an SSH hop compared to TUI Browser's direct `WebSocket → node-pty → tmux attach` path. They're designed for accessing remote machines from a browser, not for mirroring your own desktop terminal. - -### Terminal Sharing / Collaboration - -Tools designed for sharing your terminal with others. They create or wrap sessions for collaborative access — different goal than personal mobile access. - -| Tool | Stack | What it does | Attach existing tmux? | -|------|-------|-------------|----------------------| -| **[tmate](https://github.com/tmate-io/tmate)** | C (tmux fork) | ~6k stars. Forks tmux itself, tunnels via SSH to a relay server. Read-only and read-write URLs. Self-hostable server. | **No** — creates its own tmux sessions. Cannot attach to existing ones. | -| **[sshx](https://github.com/ekzhang/sshx)** | Rust | ~7k stars. Infinite canvas with multiple terminal panes, real-time collaboration cursors, E2E encrypted. | **No** — creates its own sessions. | -| **[Upterm](https://github.com/owenthereal/upterm)** | Go | ~1.2k stars. Reverse SSH tunnel, collaborators connect via standard `ssh` command. Self-hostable relay. | Shares the current command/shell, not tmux-aware. | -| **[TermPair](https://github.com/cs01/termpair)** | Python | ~1.7k stars. AES-GCM E2E encrypted, server is a blind router. Multiple browser viewers. | Shares the terminal it runs in — not tmux-specific. | -| **[WebTTY](https://github.com/maxmcd/webtty)** | Go | ~2.8k stars. **WebRTC peer-to-peer** — no relay server for data. Signaling via copy-paste. | **No** — single host, single viewer. | -| **[tty-share](https://github.com/elisescu/tty-share)** | Go | Shareable URL for your terminal. Browser or CLI viewer. | No tmux integration. | - -### Native Resilient Connections (No Browser) - -These solve connection resilience (surviving network changes, sleep/wake) but have no browser story. Great for terminal-to-terminal access alongside TUI Browser. - -| Tool | Transport | Key advantage | Limitation | -|------|-----------|--------------|------------| -| **[Mosh](https://mosh.org/)** | UDP (State Sync Protocol) | Survives IP changes, sleep/wake, high latency. Predictive local echo makes typing feel instant. | No scrollback (need tmux), no port forwarding, requires UDP 60000-61000 open. No browser client (Chrome NaCl extension is dead). | -| **[Eternal Terminal](https://github.com/MisterTea/EternalTerminal)** | TCP | Like Mosh but with scrollback, port forwarding, uses TCP (easier firewalls). Auto-reconnects seamlessly. | No browser client. C++, ~3.6k stars. | - -Both can attach to existing tmux sessions (`mosh host -- tmux attach`, `et host -c "tmux attach"`). They complement TUI Browser — use them from a real terminal, use TUI Browser from a browser. - -### Enterprise / Heavyweight - -| Tool | Stack | Notes | -|------|-------|-------| -| **[Apache Guacamole](https://guacamole.apache.org/)** | Java + C daemon (guacd) | Clientless remote desktop gateway — SSH, RDP, VNC, Telnet, Kubernetes. Server-side terminal rendering (not xterm.js). Multi-user viewing, session recording, SFTP. Heavy infrastructure (Java webapp + guacd + database). | -| **[JumpServer](https://github.com/jumpserver/jumpserver)** | Python/Django | ~30k stars. Full PAM platform — RBAC, audit trails, session recording, AD/LDAP/SAML. SSH, RDP, VNC, K8s, databases. Enterprise access management. | - -Both are designed for multi-user access governance, not personal terminal mirroring. - -### Protocol Comparison - -| Approach | Transport | Path to terminal | Trade-off | -|----------|-----------|-----------------|-----------| -| **TUI Browser** | WebSocket (TCP) | `Browser → WS → node-pty → tmux attach` | Direct, minimal hops. No auth overhead (Tailscale handles it). | -| **SSH proxies** | WebSocket → SSH (TCP) | `Browser → WS → ssh2 → sshd → shell` | Extra hop + SSH auth on every connection. | -| **Mosh** | UDP | `Client → SSP → mosh-server → shell` | State sync (not stream), survives network changes. No browser. | -| **Guacamole** | WebSocket → Guacamole protocol (TCP) | `Browser → WS → Java → guacd → SSH/VNC` | Server-side rendering. Heavy but protocol-agnostic. | -| **WebRTC** | UDP (P2P) | `Browser → DataChannel → peer` | True P2P after signaling. Not widely adopted for terminals. | -| **tmate** | SSH (TCP) | `tmate client → msgpack → relay → SSH → viewer` | tmux state sync over SSH tunnel. Can't attach to real tmux. | - -
- -TUI Browser sits in a unique spot: it's a **stateless web bridge to your existing terminal sessions** — it discovers running tmux sessions, exposes them over WebSocket, and lets multiple clients attach without configuration. The terminal sessions are the source of truth; the web layer is a view into them, not a replacement. No other tool does this. - -
- ---- - -## Gotchas & Tips - -### Kitty + tmux - -Running Kitty windows inside tmux breaks a few things (tab CWD, titles, Shift+Enter). See [docs/kitty-tmux-integration.md](docs/kitty-tmux-integration.md) for fixes. - -### tmux Tips - -- **Scroll up**: mouse wheel scrolls the buffer when `mouse on` is set in `~/.tmux.conf` (the install script enables this). -- **Select text**: hold `Shift` while clicking/dragging to use your terminal's native selection — this bypasses tmux's copy mode, which otherwise jumps to the bottom after selecting. -- **Copy-mode (keyboard)**: `Ctrl+b` then `[` enters copy mode. Use arrow keys / `Page Up` / `Page Down` to scroll. Press `q` to exit. - -### AI Session Titles - -If the [Claude CLI](https://claude.com/claude-code) is installed, sessions can be auto-titled based on their terminal content: - -- **Automatic**: new sessions get a title once they cross 15 lines of output (one-time, uses haiku model) -- **Manual**: click the sparkle icon next to the session name in terminal view to regenerate -- **Smart context**: extracts first 150 + last 150 lines of the last command's output (skips the middle for long outputs) -- **Human-safe**: manually renamed sessions are never auto-overwritten - -### File Browser Symlinks - -The file browser resolves symlinks to their **real path** before checking access. If a symlink inside `$HOME` points to `/opt/something`, the resolved path must fall within one of your configured `allowedRoots` in `data/file-browser-config.json` — otherwise access is denied. - -This is intentional: symlinks should not be an escape hatch out of the allowed directory sandbox. To fix it, add the symlink's target directory to your `allowedRoots`: - -```json -{ - "allowedRoots": ["$HOME", "/opt/something"] -} -``` - -Then restart the server. - -### Mobile Controls - -The terminal view includes touch-optimized controls: - -- **Quick-keys bar** — Esc, Tab, Ctrl+C/D/Z, arrow keys, Sel (text select), and a pen icon to open the text input panel. Always visible on mobile, toggled via the pill button on desktop/tablet. -- **Text input panel** — compose text freely, then send to terminal in one shot. Enter sends, Shift+Enter for newlines, auto-expands up to 5 lines. Fullscreen mode for longer text. -- **Scroll controls** — floating up/down buttons (top-right) to scroll tmux history via copy-mode -- **Text selection** — tap Sel to open terminal output in a native-selectable overlay with Copy All -- **Keyboard awareness** — UI shifts above the soft keyboard automatically -- **Double-tap** a session card on the dashboard to connect directly - ---- - -
-

API Reference

- -### REST - -| Method | Endpoint | Description | -|--------|----------|-------------| -| `GET` | `/api/discover` | Unified discovery (tmux sessions + Kitty windows) | -| `GET` | `/api/version` | Server version + build ID + claude availability | -| `GET` | `/api/identity` | Server name + package version (for federation) | -| `GET` | `/api/health` | Server + tmux + Kitty status | -| `GET` | `/api/servers` | Multi-server configuration list | -| `PUT` | `/api/servers` | Update server list `{ servers: [{ name, url }] }` | -| `GET` | `/api/update/status` | Check if self-update is in progress | -| `POST` | `/api/update` | Trigger git pull + npm install + restart | -| `GET` | `/api/sessions` | List tmux sessions | -| `GET` | `/api/sessions/:name` | Session details + preview | -| `GET` | `/api/sessions/:name/info` | Live session stats (memory, CPU, processes, output) | -| `GET` | `/api/sessions/:name/claude-status` | Detect Claude Code session + remote-control URL | -| `GET` | `/api/kitty/windows` | Kitty window discovery (debug, prefer `/api/discover`) | -| `POST` | `/api/sessions` | Create session `{ name, command, cwd }` | -| `POST` | `/api/sessions/bulk-kill` | Bulk kill `{ names[], filter?, inactiveMinutes? }` | -| `POST` | `/api/sessions/:name/rename` | Rename `{ newName }` | -| `POST` | `/api/sessions/:name/open-terminal` | Open Kitty window for session | -| `POST` | `/api/sessions/:name/generate-title` | AI-generate session title via Claude CLI | -| `POST` | `/api/shortcuts` | Add custom shortcut `{ label, command }` | -| `DELETE` | `/api/sessions/:name` | Kill session | -| **File Browser** | | | -| `POST` | `/api/files/list` | List directory contents `{ path, showHidden? }` | -| `POST` | `/api/files/read` | Read text file `{ path }` — returns content or binary/size flag | -| `POST` | `/api/files/write` | Save file `{ path, content }` | -| `POST` | `/api/files/upload` | Upload files (multipart, field: `filepond`, query: `targetDir`) | -| `GET` | `/api/files/download` | Download file or zip folder `?path=...` | -| `POST` | `/api/files/mkdir` | Create directory `{ path }` (recursive, idempotent) | -| `POST` | `/api/files/rename` | Rename `{ oldPath, newPath }` — 409 if target exists | -| `POST` | `/api/files/delete` | Delete file/folder `{ path }` (recursive for dirs) | -| `POST` | `/api/files/move` | Move `{ src, dest, overwrite? }` — 409 if exists | -| `POST` | `/api/files/copy` | Copy `{ src, dest, overwrite? }` — 409 if exists | -| `GET` | `/api/files/cwd` | Get tmux session CWD `?session=name` | - -### WebSocket - -Connect to `/ws/terminal/:sessionName`: - -```js -// Client → Server -{ "type": "attach", "cols": 80, "rows": 24 } -{ "type": "input", "data": "ls\r" } -{ "type": "resize", "cols": 120, "rows": 40 } - -// Server → Client -// Raw terminal output (ANSI preserved) or: -{ "type": "session-ended", "sessionName": "..." } -``` - -
- -
-

Project Structure

- -``` -tui-browser/ -├── server/ -│ ├── index.js # HTTP + WebSocket server orchestrator -│ ├── routes.js # All REST API route handlers -│ ├── state.js # Persistent state (display titles, locks) -│ ├── ai-titles.js # AI title generation via Claude CLI -│ ├── session-manager.js # PTY lifecycle, multi-client, Kitty launch -│ ├── discovery.js # tmux + unified discovery with PID matching -│ ├── kitty-discovery.js # Kitty remote control discovery -│ ├── claude-detect.js # Claude Code session + remote-control detection -│ ├── file-routes.js # File browser REST API (browse, edit, upload, download) -│ ├── identity.js # Server identity (name + version for federation) -│ ├── servers.js # Multi-server config CRUD (data/servers.json) -│ ├── tui-overrides.js # tui.json discovery and caching -│ ├── update.js # Self-update endpoint (git pull + restart) -│ └── exec-util.js # Shared subprocess utility -├── public/ -│ ├── index.html # SPA shell -│ ├── js/ -│ │ ├── app.js # Hash router, modal, toast, version polling -│ │ ├── server-manager.js # Multi-server connection manager + discovery aggregator -│ │ ├── settings-panel.js # Server settings overlay (add/edit/remove servers) -│ │ ├── dashboard.js # Session cards, server groups, rendering, CRUD -│ │ ├── dashboard-shortcuts.js # Quick Launch dropdown -│ │ ├── dashboard-bulk-kill.js # Selection + bulk kill modal -│ │ ├── dashboard-info.js # Session info overlay -│ │ ├── terminal.js # xterm.js setup + WebSocket connection -│ │ ├── terminal-text-input.js # Compose-and-send text panel + quickbar toggle -│ │ ├── terminal-controls.js # Scroll, text select, session ops -│ │ ├── terminal-notes.js # Sent history + persistent notes scratchpad -│ │ ├── file-browser.js # File browser overlay (navigation, context menu, selection) -│ │ ├── file-editor.js # CodeMirror 6 file editor (view/edit text files) -│ │ └── file-upload.js # FilePond upload overlay -│ ├── css/ -│ │ ├── base.css # Theme variables, header, buttons, modal -│ │ ├── dashboard.css # Session cards, toolbar, shortcuts -│ │ ├── terminal.css # Terminal view, quick-keys, scroll controls -│ │ ├── info-panel.css # Session info overlay + stats -│ │ └── file-browser.css # File browser, editor, upload, context menu styles -│ ├── vendor/ -│ │ ├── codemirror.bundle.js # Pre-built CodeMirror 6 (13 languages) -│ │ └── vscode-icons.js # Pre-built vscode-icons browser bundle -│ └── icons/ # vscode-icons SVGs (1,480 file type icons, gitignored) -├── scripts/ -│ ├── tmux-kitty-shell # Wrapper: launches Kitty windows inside tmux -│ ├── bump-version.sh # Pre-commit hook: auto-bump patch version -│ ├── bundle-codemirror.sh # One-time CodeMirror 6 build script -│ └── bundle-vscode-icons.sh # One-time vscode-icons build script -├── install.sh # One-command setup -└── package.json -``` - -
- ---- - -## License - -[AGPL-3.0](LICENSE) — free to use, modify, and distribute. If you modify it and offer it as a network service, you must open-source your modifications under the same license. diff --git a/install.sh b/install.sh deleted file mode 100755 index 443efba..0000000 --- a/install.sh +++ /dev/null @@ -1,333 +0,0 @@ -#!/bin/bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -YELLOW='\033[1;33m' -GREEN='\033[1;32m' -CYAN='\033[1;36m' -RED='\033[1;31m' -NC='\033[0m' - -info() { echo -e "${GREEN}[+]${NC} $*"; } -warn() { echo -e "${YELLOW}[!]${NC} $*"; } -err() { echo -e "${RED}[x]${NC} $*"; } -step() { echo -e "${CYAN}==> $*${NC}"; } - -# ---------- Arguments ---------- -SERVER_NAME="" -IS_PRIMARY=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --server-name) SERVER_NAME="$2"; shift 2 ;; - --primary) IS_PRIMARY=true; shift ;; - *) shift ;; - esac -done - -# ────────────────────────────────────────────── -step "Checking dependencies" -# ────────────────────────────────────────────── - -missing=() -command -v node >/dev/null || missing+=(node) -command -v npm >/dev/null || missing+=(npm) -command -v tmux >/dev/null || missing+=(tmux) - -if [ ${#missing[@]} -gt 0 ]; then - err "Missing required dependencies: ${missing[*]}" - echo " Install them before running this script." - exit 1 -fi - -info "node $(node --version), npm $(npm --version), tmux $(tmux -V)" - -# Tailscale is required for network security -if ! command -v tailscale &>/dev/null; then - err "Tailscale is required but not installed." - echo " Install: https://tailscale.com/download" - echo " Then run: sudo tailscale up" - exit 1 -fi - -if ! tailscale status &>/dev/null; then - err "Tailscale is installed but not running." - echo " Start it with: sudo tailscale up" - exit 1 -fi - -TAILSCALE_IP=$(tailscale ip -4 2>/dev/null) -if [ -z "$TAILSCALE_IP" ]; then - err "Could not detect Tailscale IPv4 address." - echo " Make sure Tailscale is connected: tailscale status" - exit 1 -fi - -info "Tailscale IP: $TAILSCALE_IP" - -# Optional -if command -v kitty >/dev/null; then - info "kitty found (optional Kitty integration available)" - HAS_KITTY=1 -else - warn "kitty not found — Kitty integration will be skipped" - HAS_KITTY=0 -fi - -# ────────────────────────────────────────────── -step "Installing npm dependencies" -# ────────────────────────────────────────────── - -cd "$SCRIPT_DIR" -npm install --production - -# ---------- Server Identity ---------- - -if [ -z "$SERVER_NAME" ]; then - read -p "Enter a name for this server (e.g., desktop, laptop): " SERVER_NAME - SERVER_NAME="${SERVER_NAME:-default}" -fi - -mkdir -p "$SCRIPT_DIR/data" -cat > "$SCRIPT_DIR/data/identity.json" << IDEOF -{ - "name": "$SERVER_NAME" -} -IDEOF -echo "Server identity set to: $SERVER_NAME" - -if [ "$IS_PRIMARY" = true ] && [ ! -f "$SCRIPT_DIR/data/servers.json" ]; then - cat > "$SCRIPT_DIR/data/servers.json" << SEOF -{ - "servers": [ - { - "name": "$SERVER_NAME", - "url": "http://$TAILSCALE_IP:7483" - } - ] -} -SEOF - echo "Initialized servers.json with self as primary." -fi - -# ────────────────────────────────────────────── -step "Setting up tmux-kitty-shell wrapper" -# ────────────────────────────────────────────── - -mkdir -p ~/.local/bin -cp "$SCRIPT_DIR/scripts/tmux-kitty-shell" ~/.local/bin/tmux-kitty-shell -chmod +x ~/.local/bin/tmux-kitty-shell -info "Installed ~/.local/bin/tmux-kitty-shell" - -# ────────────────────────────────────────────── -step "Setting up tmux configuration" -# ────────────────────────────────────────────── - -TMUX_CONF="$HOME/.tmux.conf" -MARKER="# --- tui-browser managed ---" - -# Check if we already wrote our block -if [ -f "$TMUX_CONF" ] && grep -qF "$MARKER" "$TMUX_CONF"; then - info "tmux.conf already configured (found marker), skipping" -else - if [ -f "$TMUX_CONF" ]; then - warn "Existing ~/.tmux.conf found — appending TUI Browser settings" - fi - cat >> "$TMUX_CONF" <<'TMUXEOF' - -# --- tui-browser managed --- -# Terminal capabilities -set -g default-terminal "tmux-256color" -set -ag terminal-overrides ",xterm-kitty:RGB" -set -g allow-passthrough on -set -sg escape-time 0 -set -g extended-keys on -set -as terminal-features 'xterm*:extkeys' - -# UTF-8 support -set-window-option -q -g utf8 on - -# Size to the most recently active client, not the smallest -set -g window-size latest -set -g aggressive-resize on - -# Mouse support -set -g mouse on - -# Hide status bar (tui-browser provides its own UI) -set -g status off - -# Forward pane titles to kitty -set -g set-titles on -set -g set-titles-string '#T' - -# Keep windows alive after process exit -set -g remain-on-exit on - -# Cursor shape passthrough (blinking beam) -set -ga terminal-overrides ',*:Ss=\E[%p1%d q:Se=\E[5 q' - -# Clipboard and events -set -g set-clipboard on -set -g focus-events on -# --- end tui-browser managed --- -TMUXEOF - info "Wrote tmux settings to $TMUX_CONF" -fi - -# ────────────────────────────────────────────── -step "Setting up systemd user service" -# ────────────────────────────────────────────── - -SERVICE_DIR="$HOME/.config/systemd/user" -mkdir -p "$SERVICE_DIR" - -# Detect node path (Volta, nvm, or system) -NODE_BIN="$(command -v node)" -NODE_DIR="$(dirname "$NODE_BIN")" - -cat > "$SERVICE_DIR/tui-browser.service" < "$SERVICE_DIR/tui-browser-watch.path" < "$SERVICE_DIR/tui-browser-watch.service" </dev/null; then - loginctl enable-linger "$(whoami)" 2>/dev/null || true - info "Linger enabled — service will start at boot, even before login" -fi - -# ---------- Git Pre-Commit Hook (auto version bump) ---------- - -HOOK_TARGET="$SCRIPT_DIR/.git/hooks/pre-commit" -if [ ! -f "$HOOK_TARGET" ]; then - ln -sf ../../scripts/bump-version.sh "$HOOK_TARGET" - echo "Installed pre-commit hook for auto version bumping." -else - echo "Pre-commit hook already exists, skipping." -fi - -# ────────────────────────────────────────────── -step "Starting the service" -# ────────────────────────────────────────────── - -systemctl --user restart tui-browser.service -systemctl --user restart tui-browser-watch.path -sleep 1 - -if systemctl --user is-active --quiet tui-browser.service; then - info "TUI Browser is running!" - echo "" - echo -e " ${GREEN}http://$TAILSCALE_IP:7483${NC} (Tailscale only)" - echo "" -else - err "Service failed to start. Check logs with:" - echo " journalctl --user -u tui-browser.service -n 20" -fi - -# ────────────────────────────────────────────── -step "Service management commands" -# ────────────────────────────────────────────── - -echo "" -echo " Start: systemctl --user start tui-browser" -echo " Stop: systemctl --user stop tui-browser" -echo " Restart: systemctl --user restart tui-browser" -echo " Logs: journalctl --user -u tui-browser -f" -echo " Status: systemctl --user status tui-browser" -echo "" - -# ────────────────────────────────────────────── -# Manual steps (printed, not automated) -# ────────────────────────────────────────────── - -echo "" -echo -e "${YELLOW}════════════════════════════════════════════════════════${NC}" -echo -e "${YELLOW} MANUAL SETUP REQUIRED${NC}" -echo -e "${YELLOW}════════════════════════════════════════════════════════${NC}" -echo "" - -if [ "$HAS_KITTY" -eq 1 ]; then - KITTY_CONF="$HOME/.config/kitty/kitty.conf" - echo -e " ${CYAN}1. Kitty config${NC} ($KITTY_CONF)" - echo "" - - # Check if already configured - if [ -f "$KITTY_CONF" ] && grep -qF "tmux-kitty-shell" "$KITTY_CONF"; then - echo -e " ${GREEN}Already configured!${NC}" - else - echo " Add/change these lines:" - echo "" - echo -e " ${GREEN}allow_remote_control yes${NC}" - echo -e " ${GREEN}listen_on unix:/tmp/kitty-socket${NC}" - echo -e " ${GREEN}shell $HOME/.local/bin/tmux-kitty-shell${NC}" - echo "" - echo " Then restart Kitty for changes to take effect." - fi -else - echo " Kitty not installed — skip Kitty config." -fi - -echo "" -echo -e " ${CYAN}2. Locale (if Unicode looks broken)${NC}" -echo "" -echo " Add to your ~/.zshrc or ~/.bashrc:" -echo "" -echo -e " ${GREEN}export LANG=en_IN.UTF-8${NC}" -echo -e " ${GREEN}export LC_ALL=en_IN.UTF-8${NC}" -echo "" -echo -e "${YELLOW}════════════════════════════════════════════════════════${NC}" -echo "" -info "Setup complete!" diff --git a/package.json b/package.json deleted file mode 100644 index a8e2fa7..0000000 --- a/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "tui-browser", - "version": "4.1.20", - "description": "Browser-based terminal with remote control via tmux session management", - "main": "server/index.js", - "scripts": { - "start": "node server/index.js" - }, - "license": "AGPL-3.0-only", - "dependencies": { - "archiver": "^6.0.2", - "express": "^4.21.0", - "multer": "^1.4.4-lts.1", - "node-pty": "^1.0.0", - "vscode-icons-js": "^11.6.1", - "ws": "^8.18.0" - } -} diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..a1c3924 --- /dev/null +++ b/public/css/styles.css @@ -0,0 +1,545 @@ +/* ========== Reset & Base ========== */ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap'); + +:root { + --bg: #000000; + --surface: #0a0a0a; + --surface-raised: #111111; + --surface-hover: #1a1a1a; + --border: #1e1e1e; + --border-hover: #333; + --text: #d4d4d4; + --text-dim: #666; + --text-muted: #444; + --accent: #00e5a0; + --accent-dim: #00e5a022; + --accent-hover: #00ffb3; + --danger: #ff4057; + --danger-dim: #ff405722; + --kitty: #a78bfa; + --kitty-dim: #a78bfa22; + --blue: #38bdf8; + --orange: #fb923c; + --mono: 'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace; + --sans: 'DM Sans', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--sans); + height: 100dvh; + display: flex; + flex-direction: column; + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +/* ========== Header ========== */ +#header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +#header h1 { + font-family: var(--mono); + font-size: 14px; + font-weight: 600; + color: var(--accent); + letter-spacing: 1.5px; + text-transform: uppercase; +} + +#header a { text-decoration: none; } + +#header .nav-btn { + padding: 6px 14px; + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-dim); + font-family: var(--mono); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + display: none; +} + +#header .nav-btn:hover { background: var(--surface-hover); color: var(--text); border-color: var(--border-hover); } + +/* ========== Views ========== */ +#dashboard-view, +#terminal-view { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.hidden { display: none !important; } + +/* ========== Dashboard ========== */ +.dashboard-content { + flex: 1; + overflow-y: auto; + padding: 20px 16px; +} + +.dashboard-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.dashboard-toolbar h2 { + font-family: var(--mono); + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 2px; + flex: 1; +} + +/* ========== Create Session Form ========== */ +.create-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.create-form input { + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-family: var(--mono); + font-size: 12px; + outline: none; + min-width: 130px; + transition: border-color 0.15s; +} + +.create-form input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); } + +.create-form input::placeholder { color: var(--text-muted); } + +/* ========== Buttons ========== */ +.btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + font-family: var(--mono); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + min-height: 38px; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 0.15s; + letter-spacing: 0.3px; +} + +.btn-primary { + background: var(--accent); + color: #000; +} +.btn-primary:hover { background: var(--accent-hover); } + +.btn-secondary { + background: var(--surface-raised); + color: var(--text-dim); + border: 1px solid var(--border); +} +.btn-secondary:hover { background: var(--surface-hover); color: var(--text); } + +.btn-danger { + background: var(--danger-dim); + color: var(--danger); + border: 1px solid #ff405733; +} +.btn-danger:hover { background: #ff405744; color: #fff; } + +/* ========== Session Cards ========== */ +#session-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 10px; +} + +.session-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + transition: all 0.2s; + position: relative; +} + +.session-card:hover { + border-color: var(--border-hover); + background: var(--surface-raised); +} + +.session-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.session-name { + font-family: var(--mono); + font-size: 14px; + font-weight: 600; + color: var(--text); + display: flex; + align-items: center; +} + +.session-status { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--mono); + font-size: 11px; + color: var(--text-dim); +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-muted); +} + +.status-dot.attached { background: var(--accent); box-shadow: 0 0 6px var(--accent-dim); } +.status-dot.detached { background: var(--orange); } +.status-dot.web-connected { background: var(--blue); box-shadow: 0 0 6px #38bdf822; } + +.session-meta { + font-family: var(--mono); + font-size: 11px; + color: var(--text-muted); + margin-bottom: 10px; + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.session-meta span { + display: flex; + align-items: center; + gap: 4px; +} + +.session-preview { + background: var(--bg); + border-radius: 6px; + padding: 8px 10px; + font-family: var(--mono); + font-size: 11px; + color: var(--text-muted); + max-height: 80px; + overflow: hidden; + white-space: pre; + margin-bottom: 10px; + line-height: 1.4; + border: 1px solid var(--border); +} + +.session-actions { + display: flex; + gap: 8px; +} + +/* ========== Source Section Headers ========== */ +.source-section { + grid-column: 1 / -1; + margin-top: 12px; +} + +.source-section:first-child { + margin-top: 0; +} + +.source-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.source-badge { + font-family: var(--mono); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + padding: 2px 8px; + border-radius: 4px; +} + +.kitty-badge { + background: var(--kitty-dim); + color: var(--kitty); + border: 1px solid #a78bfa33; +} + +.tmux-badge { + background: var(--accent-dim); + color: var(--accent); + border: 1px solid #00e5a033; +} + +.source-label { + font-size: 11px; + color: var(--text-muted); +} + +.unmatched-kitty-section .source-label { + color: var(--text-muted); + font-style: italic; +} + +.unmatched-card { + opacity: 0.4; +} + +/* ========== Kitty Cards ========== */ +.kitty-card { + border-left: 2px solid var(--kitty); +} + +.source-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + font-family: var(--mono); + font-size: 9px; + font-weight: 700; + margin-right: 6px; + flex-shrink: 0; +} + +.kitty-icon { + background: var(--kitty-dim); + color: var(--kitty); +} + +/* ========== Empty State ========== */ +.empty-state { + text-align: center; + padding: 60px 16px; + color: var(--text-muted); +} + +.empty-state h3 { + font-family: var(--mono); + font-size: 14px; + margin-bottom: 8px; + color: var(--text-dim); + font-weight: 500; +} + +.empty-state p { + font-size: 13px; + color: var(--text-muted); +} + +/* ========== Modal ========== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal { + background: var(--surface-raised); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + max-width: 360px; + width: 90%; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5); +} + +.modal p { + font-size: 14px; + margin-bottom: 20px; + line-height: 1.6; + color: var(--text); +} + +.modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +/* ========== Terminal View ========== */ +.terminal-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--bg); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + flex-wrap: wrap; +} + +.terminal-toolbar .session-label { + font-family: var(--mono); + font-size: 12px; + color: var(--accent); + font-weight: 600; + letter-spacing: 0.5px; +} + +.zoom-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.zoom-btn { + width: 32px; + height: 32px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text-dim); + font-size: 16px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + user-select: none; + position: relative; + z-index: 10; + transition: all 0.15s; +} + +.zoom-btn:hover { background: var(--surface-hover); color: var(--text); border-color: var(--border-hover); } + +#zoom-level { + font-family: var(--mono); + font-size: 10px; + color: var(--text-muted); + min-width: 22px; + text-align: center; +} + +.reconnect-btn { + width: 32px; + height: 32px; + border: 1px solid #ff405733; + border-radius: 6px; + background: var(--danger-dim); + color: var(--danger); + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + touch-action: manipulation; + transition: all 0.15s; +} + +.reconnect-btn:hover { background: #ff405744; color: #fff; } + +.terminal-toolbar .connection-status { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--mono); + font-size: 11px; + color: var(--text-dim); + margin-left: auto; +} + +#terminal-container { + flex: 1; + padding: 2px; + overflow: hidden; + background: var(--bg); +} + +#terminal-container .xterm { height: 100%; } + +/* ========== Responsive ========== */ +@media (max-width: 768px) { + #session-list { + grid-template-columns: 1fr; + } + + .dashboard-toolbar { + flex-direction: column; + align-items: stretch; + } + + .create-form { + flex-direction: column; + } + + .create-form input { + width: 100%; + } + + #header h1 { font-size: 12px; } + + .terminal-toolbar { + padding: 6px 10px; + } + + .session-card { + padding: 12px 14px; + } + + .btn { + min-height: 42px; + } +} + +/* ========== Scrollbar ========== */ +.dashboard-content::-webkit-scrollbar { + width: 4px; +} + +.dashboard-content::-webkit-scrollbar-track { + background: var(--bg); +} + +.dashboard-content::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 2px; +} + +.dashboard-content::-webkit-scrollbar-thumb:hover { + background: var(--border-hover); +} diff --git a/public/icon-192.svg b/public/icon-192.svg index 328ce03..7ef9af2 100644 --- a/public/icon-192.svg +++ b/public/icon-192.svg @@ -1,40 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/public/icon-512-flat.svg b/public/icon-512-flat.svg index 328ce03..7ef9af2 100644 --- a/public/icon-512-flat.svg +++ b/public/icon-512-flat.svg @@ -1,40 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/public/icon-512.svg b/public/icon-512.svg index 328ce03..7ef9af2 100644 --- a/public/icon-512.svg +++ b/public/icon-512.svg @@ -1,40 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png index 56f2349..f518613 100644 Binary files a/public/icons/icon-192.png and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512-flat.png b/public/icons/icon-512-flat.png index 52506d7..89f08cc 100644 Binary files a/public/icons/icon-512-flat.png and b/public/icons/icon-512-flat.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png index 52506d7..89f08cc 100644 Binary files a/public/icons/icon-512.png and b/public/icons/icon-512.png differ diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 2e0997b..0000000 --- a/public/index.html +++ /dev/null @@ -1,365 +0,0 @@ - - - - - - - - - - - - - TUI Browser - - - - - - - - - - - - - - -
-
-
-

Sessions

-
-
- - -
-
- - -
-
- - -
- - -
-
- -
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/js/app.js b/public/js/app.js deleted file mode 100644 index 9802df3..0000000 --- a/public/js/app.js +++ /dev/null @@ -1,265 +0,0 @@ -/** - * app.js — SPA router, modal, toast, version polling. - */ - -/* global Dashboard, TerminalView, FileBrowser, FileEditor, FileUpload, ServerManager, SettingsPanel */ - -const App = (() => { - let currentView = 'dashboard'; - let currentSession = null; - let currentServer = null; - - const views = { - dashboard: () => document.getElementById('dashboard-view'), - terminal: () => document.getElementById('terminal-view'), - files: () => document.getElementById('file-browser-overlay'), - }; - - function navigate(view, params = {}) { - if (view === 'terminal' && params.session) { - if (params.server) { - window.location.hash = `#terminal/${encodeURIComponent(params.server)}/${encodeURIComponent(params.session)}`; - } else { - window.location.hash = `#terminal/${encodeURIComponent(params.session)}`; - } - } else { - window.location.hash = '#dashboard'; - } - } - - function handleRoute() { - const hash = window.location.hash.slice(1) || 'dashboard'; - const parts = hash.split('/'); - const view = parts[0]; - - views.dashboard().classList.add('hidden'); - views.terminal().classList.add('hidden'); - - const backBtn = document.getElementById('back-btn'); - - if (view === 'files') { - const encodedPath = parts.slice(1).join('/'); - const initialPath = encodedPath ? decodeURIComponent(encodedPath) : null; - FileBrowser.open(initialPath); - return; - } - - if (view === 'terminal' && parts[1]) { - let sessionName, serverName; - if (parts.length >= 3) { - serverName = decodeURIComponent(parts[1]); - sessionName = decodeURIComponent(parts[2]); - } else { - sessionName = decodeURIComponent(parts[1]); - serverName = null; - } - currentView = 'terminal'; - currentSession = sessionName; - currentServer = serverName || null; - views.terminal().classList.remove('hidden'); - backBtn.style.display = 'inline-flex'; - document.getElementById('terminal-session-name').textContent = sessionName; - TerminalView.connect(sessionName, serverName); - const origin = serverName ? ServerManager.getOrigin(serverName) : ''; - fetch(`${origin}/api/sessions/${encodeURIComponent(sessionName)}`).then(r => r.json()).then(d => { - if (d.displayTitle) document.getElementById('terminal-session-name').textContent = d.displayTitle; - }).catch(() => {}); - } else { - currentView = 'dashboard'; - currentSession = null; - currentServer = null; - views.dashboard().classList.remove('hidden'); - backBtn.style.display = 'none'; - clearOverlayStack(); - TerminalView.disconnect(); - Dashboard.refresh(); - } - } - - // ---------- Toast ---------- - - let toastTimer = null; - - function showToast(message, type, duration) { - let toast = document.getElementById('app-toast'); - if (!toast) { - toast = document.createElement('div'); - toast.id = 'app-toast'; - toast.className = 'toast'; - document.body.appendChild(toast); - } - if (toastTimer) clearTimeout(toastTimer); - toast.textContent = message; - toast.className = `toast toast-${type} visible`; - toastTimer = setTimeout(() => { toast.classList.remove('visible'); }, duration || 3000); - } - - function formatTimestamp(ms) { - const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; - } - - // ---------- Version polling ---------- - - let knownVersion = null; - - function startVersionPolling() { - fetch('/api/version').then(r => r.json()).then(d => { - knownVersion = d.version; - const label = document.getElementById('version-label'); - if (label) label.textContent = `v${d.version}`; - if (d.startedAt) { - const el = document.getElementById('updated-label'); - if (el) el.textContent = `last updated: ${formatTimestamp(d.startedAt)}`; - } - }).catch(() => {}); - - setInterval(() => { - fetch('/api/version').then(r => r.json()).then(d => { - if (knownVersion && d.version !== knownVersion) { - knownVersion = d.version; - showToast('Update available \u2014 reloading\u2026', 'success'); - setTimeout(() => window.location.reload(), 1500); - } - }).catch(() => {}); - }, 30000); - } - - // ---------- High Contrast ---------- - - function initContrastToggle() { - const saved = localStorage.getItem('tui_high_contrast'); - if (saved === '1') document.documentElement.classList.add('high-contrast'); - document.getElementById('contrast-btn').addEventListener('click', () => { - const on = document.documentElement.classList.toggle('high-contrast'); - localStorage.setItem('tui_high_contrast', on ? '1' : '0'); - }); - } - - // ---------- Online / Offline ---------- - - function initConnectivityToasts() { - window.addEventListener('offline', () => showToast('You are offline', 'warning')); - window.addEventListener('online', () => showToast('Back online', 'success', 3000)); - } - - // ---------- Modal ---------- - - function getModalElements() { - return { - overlay: document.getElementById('modal-overlay'), - msg: document.getElementById('modal-message'), - confirmBtn: document.getElementById('modal-confirm'), - cancelBtn: document.getElementById('modal-cancel'), - }; - } - - function showModal(message, confirmLabel = 'Confirm') { - return new Promise((resolve) => { - const { overlay, msg, confirmBtn, cancelBtn } = getModalElements(); - msg.textContent = message; - confirmBtn.title = confirmLabel; - overlay.classList.remove('hidden'); - - function cleanup(result) { - overlay.classList.add('hidden'); - confirmBtn.removeEventListener('click', onConfirm); - cancelBtn.removeEventListener('click', onCancel); - resolve(result); - } - function onConfirm() { cleanup(true); } - function onCancel() { cleanup(false); } - - confirmBtn.addEventListener('click', onConfirm); - cancelBtn.addEventListener('click', onCancel); - }); - } - - // ---------- Overlay History Stack ---------- - - const overlayStack = []; - let overlayPopping = 0; // counter to eat popstate events from programmatic history.back() - - function pushOverlay(name, closeFn) { - overlayStack.push({ name, closeFn }); - history.pushState({ overlay: name }, ''); - } - - function popOverlay(name) { - const idx = overlayStack.findLastIndex(e => e.name === name); - if (idx === -1) return; - overlayStack.splice(idx, 1); - overlayPopping++; - history.back(); - } - - function clearOverlayStack() { - overlayStack.length = 0; - } - - function handlePopState(e) { - if (overlayPopping > 0) { - overlayPopping--; - return; - } - // Back button pressed — check if we have an overlay to close - if (overlayStack.length > 0) { - const entry = overlayStack.pop(); - entry.closeFn(); - return; - } - // No overlay — let hashchange handle normal navigation - } - - // ---------- Init ---------- - - function init() { - window.addEventListener('hashchange', handleRoute); - window.addEventListener('popstate', handlePopState); - document.getElementById('back-btn').addEventListener('click', () => navigate('dashboard')); - - // Sync inits (DOM setup only — no network calls) - TerminalView.init(); - TerminalNotes.initNotesOverlay(); - FileBrowser.init(); - FileEditor.init(); - FileUpload.init(); - SettingsPanel.init(); - initConnectivityToasts(); - initContrastToggle(); - - // Dashboard init — renders from cache immediately - Dashboard.init(); - - // Show the correct view immediately - handleRoute(); - - // Non-blocking network calls in parallel - // onUpdate callback just re-renders — does NOT trigger another discovery cycle - ServerManager.init(() => Dashboard.renderMultiServer()).then(() => { - // After ServerManager has states, trigger first real refresh - Dashboard.refresh(); - }); - - // These fire in parallel, non-blocking - fetch('/api/identity').then(r => r.json()).then(d => { - ServerManager.setPrimaryVersion(d.version); - }).catch(() => {}); - startVersionPolling(); - - } - - return { - init, navigate, showModal, showToast, getModalElements, - pushOverlay, popOverlay, - getWsUrl: (sessionName, serverName) => { - return ServerManager.getWsUrl(serverName || 'HOST', sessionName); - }, - onNetworkChange: () => ServerManager.onNetworkChange(), - getCurrentSession: () => currentSession, - getCurrentServer: () => currentServer, - }; -})(); - -document.addEventListener('DOMContentLoaded', App.init); diff --git a/public/js/dashboard.js b/public/js/dashboard.js deleted file mode 100644 index 3532292..0000000 --- a/public/js/dashboard.js +++ /dev/null @@ -1,293 +0,0 @@ -/** - * dashboard.js — Session list UI with auto-refresh. - * Sub-modules: dashboard-shortcuts.js, dashboard-bulk-kill.js, dashboard-info.js - */ - -/* global App, DashboardShortcuts, DashboardBulkKill, DashboardInfo, ServerManager, FileBrowser */ - -const Dashboard = (() => { - let refreshInterval = null; - const REFRESH_MS = 3000; - let sortMode = 'active'; - let lastUnmatchedKitty = null; - const selectedSessions = new Set(); - - const ICON = { - connect: '', - kill: '', - monitor: '', - info: '', - lock: '', - unlock: '', - }; - - function esc(str) { - const d = document.createElement('div'); - d.textContent = str; - return d.innerHTML.replace(/'/g, '''); - } - - function init() { - document.getElementById('create-session-form').addEventListener('submit', handleCreate); - - document.getElementById('session-list').addEventListener('click', (e) => { - const btn = e.target.closest('[data-action]'); - if (!btn) return; - const action = btn.dataset.action; - if (action === 'connect') connectTo(btn.dataset.session, btn.dataset.server); - else if (action === 'open-terminal') openOnPC(btn.dataset.session, btn); - else if (action === 'info') DashboardInfo.open(btn.dataset.session, btn.dataset.server); - else if (action === 'kill') kill(btn.dataset.session, btn.dataset.server); - else if (action === 'toggle-select') DashboardBulkKill.toggleSelect(btn.dataset.session); - else if (action === 'toggle-lock') toggleLock(btn.dataset.session, btn.dataset.server); - else if (action === 'reconnect-server') { btn.classList.add('syncing'); ServerManager.reconnectServer(btn.dataset.server); } - else if (action === 'server-files') { e.stopPropagation(); FileBrowser.open(null, ServerManager.getOrigin(btn.dataset.server)); } - else if (action === 'toggle-collapse') { const n = btn.dataset.server; setCollapsed(n, !getCollapsed()[n]); renderMultiServer(); } - }); - - let lastTap = 0, lastTapSession = null; - document.getElementById('session-list').addEventListener('click', (e) => { - if (e.target.closest('[data-action]')) return; - const card = e.target.closest('.session-card[data-session]'); - if (!card) return; - const session = card.dataset.session; - const now = Date.now(); - if (session === lastTapSession && now - lastTap < 400) { connectTo(session, card.dataset.server); lastTap = 0; lastTapSession = null; } - else { lastTap = now; lastTapSession = session; } - }); - - document.getElementById('sort-select').addEventListener('change', (e) => { sortMode = e.target.value; refresh(); }); - document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refresh(); }); - - const deps = { esc, connectTo, selectedSessions, getLastSessions: () => Object.values(ServerManager.getServerStates()).flatMap(s => s.sessions || []), refresh }; - DashboardShortcuts.init(deps); - DashboardBulkKill.init(deps); - DashboardInfo.init(deps); - - // Render immediately from cache (if available) - renderMultiServer(); - startAutoRefresh(); - } - - // ---------- Data ---------- - - async function refresh() { - await ServerManager.discoverAll(); - renderMultiServer(); - } - - function startAutoRefresh() { - if (refreshInterval) clearInterval(refreshInterval); - refreshInterval = setInterval(() => { - if (!document.getElementById('dashboard-view').classList.contains('hidden')) refresh(); - }, REFRESH_MS); - } - - // ---------- Rendering ---------- - - function renderSessionCard(s) { - const pane = s.panes && s.panes[0]; - const cmd = pane ? pane.command : 'unknown'; - const paneTitle = pane && pane.title ? pane.title : ''; - const created = new Date(s.created).toLocaleString(); - const hasKitty = s.kittyWindows && s.kittyWindows.length > 0; - let statusClass = 'detached', statusLabel = 'Detached'; - if (s.webClients > 0) { statusClass = 'web-connected'; statusLabel = `${s.webClients} web client${s.webClients > 1 ? 's' : ''}`; } - else if (hasKitty && s.attached === s.kittyWindows.length) { statusClass = 'attached'; statusLabel = 'Kitty attached'; } - else if (s.attached > 0) { statusClass = 'attached'; statusLabel = hasKitty ? 'Host + Kitty attached' : 'Host attached'; } - - let kittyBadge = ''; - if (hasKitty) { - const kittyInfo = s.kittyWindows.length === 1 ? `tab: ${esc(s.kittyWindows[0].tabTitle)}` : `${s.kittyWindows.length} Kitty viewers`; - kittyBadge = `
K ${kittyInfo}${s.kittyWindows.some(w => w.isFocused) ? 'focused' : ''}
`; - } - - const label = s.displayTitle || s.name; - const isSel = selectedSessions.has(s.name), isLocked = s.locked; - - let expiryHtml = ''; - if (s.expiresAt) { - const d = new Date(s.expiresAt); - const dd = String(d.getDate()).padStart(2, '0'); - const mm = String(d.getMonth() + 1).padStart(2, '0'); - const yy = String(d.getFullYear()).slice(-2); - const hh = String(d.getHours()).padStart(2, '0'); - const mi = String(d.getMinutes()).padStart(2, '0'); - expiryHtml = `
Exp: ${dd}-${mm}-${yy},${hh}:${mi}
`; - } - - return `
-
-
${isLocked ? ICON.lock : ICON.unlock}
-
${esc(label)}${hasKitty ? 'Kitty' : ''}${statusLabel}
- ${kittyBadge} -
${esc(cmd)}${created}${paneTitle ? `${esc(paneTitle)}` : ''}
-
- - - - -
- ${expiryHtml}
`; - } - - function renderUnmatchedKitty(windows) { - let html = `
KittyNot available for mirroring — not running inside tmux
`; - for (const win of windows) { - const size = win.columns && win.lines ? `${win.columns}x${win.lines}` : ''; - html += `
K${esc(win.title)}
${win.cmdline ? `cmd: ${esc(win.cmdline)}` : ''}${size ? `${size}` : ''}${win.cwd ? `cwd: ${esc(win.cwd)}` : ''}tab: ${esc(win.tabTitle)}
`; - } - return html; - } - - function renderError(msg) { - const list = document.getElementById('session-list'); - if (list.children.length === 0) list.innerHTML = `

Cannot reach server

${esc(msg)}

`; - } - - const RECONNECT_SVG = ''; - const FILES_SVG = ''; - - // Collapsed state per server group (persisted in localStorage) - function getCollapsed() { - try { return JSON.parse(localStorage.getItem('tui_collapsed_groups') || '{}'); } catch { return {}; } - } - function setCollapsed(name, collapsed) { - const state = getCollapsed(); - if (collapsed) state[name] = true; else delete state[name]; - localStorage.setItem('tui_collapsed_groups', JSON.stringify(state)); - } - - function renderMultiServer() { - const list = document.getElementById('session-list'); - const states = ServerManager.getServerStates(); - // Ensure HOST is always first - const serverNames = ['HOST', ...Object.keys(states).filter(n => n !== 'HOST')]; - const collapsed = getCollapsed(); - - let html = ''; - let totalSessions = 0; - - for (const name of serverNames) { - const state = states[name]; - if (!state) continue; - const isOnline = state.online; - const isUpdating = state.updating; - const sessions = state.sessions || []; - const isCollapsed = !!collapsed[name]; - totalSessions += sessions.length; - - html += `
`; - html += `
`; - html += `
`; - html += `${isCollapsed ? '\u25B6' : '\u25BC'}`; - html += `${esc(name)}`; - - if (isUpdating) { - html += `updating\u2026`; - } else if (isOnline) { - html += `${sessions.length} session${sessions.length !== 1 ? 's' : ''}`; - } else { - html += `offline`; - } - - html += `
`; - html += `
`; - - if (isOnline) { - html += ``; - } - if (!isOnline) { - html += ``; - } - - html += `
`; - - if (!isCollapsed && isOnline && sessions.length > 0) { - const sorted = [...sessions].sort((a, b) => { - switch (sortMode) { - case 'recent': return b.created - a.created; - case 'oldest': return a.created - b.created; - case 'active': return (b.lastActivity || 0) - (a.lastActivity || 0); - case 'idle': return (a.lastActivity || 0) - (b.lastActivity || 0); - default: return 0; - } - }); - html += `
`; - html += sorted.map(s => renderSessionCard(s)).join(''); - html += `
`; - } - - html += `
`; - } - - if (totalSessions === 0 && serverNames.every(n => states[n] && states[n].online)) { - html += '

No sessions found

Create a new tmux session on any server to get started.

'; - } - - list.innerHTML = html; - DashboardBulkKill.updateButton(); - } - - // ---------- Session CRUD ---------- - - async function handleCreate(e) { - e.preventDefault(); - const nameInput = document.getElementById('new-session-name'); - const cmdInput = document.getElementById('new-session-cmd'); - const name = nameInput.value.trim(), command = cmdInput.value.trim() || 'bash'; - if (!name) { nameInput.focus(); return; } - try { - const targetServer = DashboardShortcuts.getTargetServer(); - const states = ServerManager.getServerStates(); - const state = states[targetServer]; - const origin = (state && !state.isHost) ? state.origin : ''; - const res = await fetch(`${origin}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, command }) }); - if (!res.ok) { const err = await res.json(); await App.showModal(err.error || 'Failed to create session', 'OK'); return; } - nameInput.value = ''; cmdInput.value = ''; - await refresh(); - } catch (err) { await App.showModal('Failed to create session: ' + err.message, 'OK'); } - } - - function connectTo(sessionName, serverName) { - if (serverName) { - App.navigate('terminal', { session: sessionName, server: serverName }); - } else { - App.navigate('terminal', { session: sessionName }); - } - } - - async function openOnPC(sessionName, btn) { - try { - btn.disabled = true; btn.style.opacity = '0.5'; - const serverName = btn.closest('.session-card')?.dataset.server; - const origin = serverName ? ServerManager.getOrigin(serverName) : ''; - await fetch(`${origin}/api/sessions/${encodeURIComponent(sessionName)}/open-terminal`, { method: 'POST' }); - setTimeout(() => { btn.disabled = false; btn.style.opacity = ''; }, 2000); - } catch { setTimeout(() => { btn.disabled = false; btn.style.opacity = ''; }, 2000); } - } - - async function kill(sessionName, serverName) { - const sessions = Object.values(ServerManager.getServerStates()).flatMap(s => s.sessions); - const s = sessions.find(x => x.name === sessionName && (!serverName || x._server === serverName)); - if (s && s.locked) return; - const label = serverName ? `${serverName}:${sessionName}` : sessionName; - const confirmed = await App.showModal(`Kill session "${label}"? This will terminate all processes in it.`, 'Kill'); - if (!confirmed) return; - try { - const origin = serverName ? ServerManager.getOrigin(serverName) : ''; - await fetch(`${origin}/api/sessions/${encodeURIComponent(sessionName)}`, { method: 'DELETE' }); - await refresh(); - } catch (err) { await App.showModal('Failed to kill session: ' + err.message, 'OK'); } - } - - async function toggleLock(name, serverName) { - try { - const origin = serverName ? ServerManager.getOrigin(serverName) : ''; - const res = await fetch(`${origin}/api/sessions/${encodeURIComponent(name)}/lock`, { method: 'POST' }); - if (res.ok) await refresh(); - } catch {} - } - - return { init, refresh, renderMultiServer }; -})(); diff --git a/public/js/terminal.js b/public/js/terminal.js deleted file mode 100644 index 17a0b02..0000000 --- a/public/js/terminal.js +++ /dev/null @@ -1,421 +0,0 @@ -/** - * terminal.js — xterm.js terminal with WebSocket connection to tmux sessions. - * Controls (scroll, text select, quickbar, session ops) are in terminal-controls.js. - */ - -/* global Terminal, FitAddon, WebglAddon, App, TerminalControls, ServerManager, FileEditor */ - -const TerminalView = (() => { - let term = null; - let fitAddon = null; - let webglAddon = null; - let ws = null; - let currentSession = null; - let heartbeatInterval = null; - let tuiPollInterval = null; - let claudeRemoteUrl = null; - let connectResolvers = []; - let isTouchUser = false; - - function optimalFontSize() { return 14; } - - function updateZoomLabel() { - const el = document.getElementById('zoom-value'); - if (el && term) el.textContent = term.options.fontSize; - } - - function reloadWebGL() { - if (webglAddon) { try { webglAddon.dispose(); } catch {} webglAddon = null; } - try { - webglAddon = new WebglAddon.WebglAddon(); - webglAddon.onContextLoss(() => { webglAddon.dispose(); webglAddon = null; }); - term.loadAddon(webglAddon); - } catch { /* software fallback */ } - } - - function setFontSize(size) { - if (!term) return; - size = Math.max(2, Math.min(32, size)); - term.options.fontSize = size; - updateZoomLabel(); - reloadWebGL(); - if (fitAddon) setTimeout(() => { fitAddon.fit(); sendResize(); }, 50); - } - - function sendResize() { - if (ws && ws.readyState === WebSocket.OPEN && term) { - ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); - } - } - - function handleResize() { - if (!fitAddon || !term) return; - if (document.getElementById('terminal-view').classList.contains('hidden')) return; - fitAddon.fit(); - sendResize(); - updateZoomLabel(); - } - - function adjustForKeyboard() { - if (window.innerWidth > 768) { document.body.style.height = ''; return; } - document.body.style.height = `${window.visualViewport.height}px`; - } - - function init() { - term = new Terminal({ - cursorBlink: true, cursorStyle: 'block', - fontSize: optimalFontSize(), - fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace", - theme: { - background: '#000000', foreground: '#d4d4d4', cursor: '#00e5a0', - cursorAccent: '#000000', selectionBackground: '#00e5a033', selectionForeground: '#ffffff', - }, - allowProposedApi: true, scrollback: 5000, - }); - - fitAddon = new FitAddon.FitAddon(); - term.loadAddon(fitAddon); - term.open(document.getElementById('terminal-container')); - - // Suppress browser right-click menu so tmux context menu is usable - document.getElementById('terminal-container').addEventListener('contextmenu', (e) => { - e.preventDefault(); - }); - - // Custom keyboard handler: Shift+Enter → CSI u sequence, Ctrl+Shift+V → paste - term.attachCustomKeyEventHandler((ev) => { - if (ev.type !== 'keydown') return true; - - // Shift+Enter → send literal newline (matches Kitty's shift+enter → \n mapping) - if (ev.key === 'Enter' && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) { - ev.preventDefault(); - term.input('\n', true); - return false; - } - - // Ctrl+Shift+V → paste from clipboard - if (ev.key === 'V' && ev.ctrlKey && ev.shiftKey && !ev.altKey && !ev.metaKey) { - ev.preventDefault(); - navigator.clipboard.readText().then((text) => { - if (text) term.input(text, true); - }).catch(() => {}); - return false; - } - - return true; - }); - - // Fix mobile keyboard input - const xtermTextarea = document.querySelector('#terminal-container .xterm-helper-textarea'); - if (xtermTextarea) { - xtermTextarea.setAttribute('autocorrect', 'off'); - xtermTextarea.setAttribute('autocapitalize', 'off'); - xtermTextarea.setAttribute('autocomplete', 'off'); - xtermTextarea.setAttribute('spellcheck', 'false'); - xtermTextarea.setAttribute('virtualkeyboardpolicy', 'manual'); - } - - // WebGL addon - try { - webglAddon = new WebglAddon.WebglAddon(); - webglAddon.onContextLoss(() => { webglAddon.dispose(); webglAddon = null; }); - term.loadAddon(webglAddon); - } catch { /* software fallback */ } - - // Batched keyboard input - let inputBuffer = ''; - let inputFlushTimer = null; - const INPUT_BATCH_MS = 30; - - term.onData((data) => { - if (!ws || ws.readyState !== WebSocket.OPEN) return; - inputBuffer += data; - if (!inputFlushTimer) { - inputFlushTimer = setTimeout(() => { - if (inputBuffer && ws && ws.readyState === WebSocket.OPEN) ws.send(inputBuffer); - inputBuffer = ''; - inputFlushTimer = null; - }, INPUT_BATCH_MS); - } - }); - - // Resize handling - window.addEventListener('resize', handleResize); - window.addEventListener('orientationchange', () => setTimeout(handleResize, 200)); - if (window.visualViewport) { - window.visualViewport.addEventListener('resize', handleResize); - window.visualViewport.addEventListener('resize', adjustForKeyboard); - } - - // Zoom buttons - document.getElementById('zoom-in-btn').addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); setFontSize(term.options.fontSize + 1); - }); - document.getElementById('zoom-out-btn').addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); setFontSize(term.options.fontSize - 1); - }); - - // Reconnect button - document.getElementById('reconnect-btn').addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); - if (currentSession) connect(currentSession, App.getCurrentServer()); - }); - - // Claude remote control button — click to copy, double-click to open - const claudeBtn = document.getElementById('claude-remote-btn'); - claudeBtn.addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); - if (!claudeRemoteUrl) return; - navigator.clipboard.writeText(claudeRemoteUrl).then(() => { - App.showToast('Remote control URL copied!'); - }); - }); - claudeBtn.addEventListener('dblclick', (e) => { - e.preventDefault(); e.stopPropagation(); - if (claudeRemoteUrl) window.open(claudeRemoteUrl, '_blank'); - }); - - // Detect touch users — gate auto-focus to prevent unwanted keyboard - window.addEventListener('touchstart', () => { isTouchUser = true; }, { once: true, passive: true }); - - // Click terminal to focus — but not on touch (keyboard button handles that) - document.getElementById('terminal-container').addEventListener('click', (e) => { - if (isTouchUser) return; - if (term) term.focus(); - }); - - // Keyboard toggle button (uses VirtualKeyboard API on Android Chrome, blur/focus fallback elsewhere) - let kbOpen = false; - let kbTapping = false; - const kbBtn = document.getElementById('keyboard-toggle-btn'); - const vk = navigator.virtualKeyboard || null; - - kbBtn.addEventListener('touchstart', () => { kbTapping = true; }, { passive: true }); - - kbBtn.addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); - kbTapping = false; - if (kbOpen) { - if (vk) vk.hide(); - else document.querySelector('#terminal-container .xterm-helper-textarea')?.blur(); - kbOpen = false; - kbBtn.classList.remove('active'); - } else if (term) { - term.focus(); - if (vk) vk.show(); - kbOpen = true; - kbBtn.classList.add('active'); - } - }); - - // Sync state when keyboard is dismissed externally (swipe down, back button, etc) - const xtArea = document.querySelector('#terminal-container .xterm-helper-textarea'); - if (xtArea) xtArea.addEventListener('blur', () => { - if (kbTapping) return; // blur caused by tapping KB button — let click handler deal with it - kbOpen = false; - kbBtn.classList.remove('active'); - }); - - // Initialize controls (scroll, text select, quickbar, session ops) - TerminalControls.init({ - term, - getWs: () => ws, - getSession: () => currentSession, - ensureConnected, - }); - - TerminalTextInput.init({ term, ensureConnected, sessionName: null }); - - updateZoomLabel(); - } - - // ---------- WebSocket Connection ---------- - - function connect(sessionName, serverName) { - // Lightweight cleanup — preserves UI state (text input panel, quickbar, scroll mode) - if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } - TerminalControls.stopScrolling(); - if (ws) { ws.onclose = null; ws.onerror = null; ws.close(); ws = null; } - - currentSession = sessionName; - TerminalTextInput.setSession(sessionName); - setStatus('connecting', 'Connecting\u2026'); - term.clear(); - - try { ws = new WebSocket(App.getWsUrl(sessionName, serverName)); } - catch { - setStatus('error', 'Failed to connect'); - const pending = connectResolvers.splice(0); - pending.forEach(r => r.reject(new Error('Failed to connect'))); - return; - } - - ws.binaryType = 'arraybuffer'; - - ws.onopen = () => { - setStatus('connected', 'Connected'); - if (heartbeatInterval) clearInterval(heartbeatInterval); - heartbeatInterval = setInterval(() => { - if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' })); - }, 30000); - - setTimeout(() => { - const newSize = optimalFontSize(); - if (term.options.fontSize !== newSize) term.options.fontSize = newSize; - fitAddon.fit(); - updateZoomLabel(); - ws.send(JSON.stringify({ type: 'attach', cols: term.cols, rows: term.rows })); - }, 50); - - if (!isTouchUser) term.focus(); - checkClaudeStatus(sessionName, serverName); - loadTuiActions(sessionName, serverName); - if (tuiPollInterval) clearInterval(tuiPollInterval); - tuiPollInterval = setInterval(() => loadTuiActions(sessionName, serverName), 30000); - - const pending = connectResolvers.splice(0); - pending.forEach(r => r.resolve()); - }; - - ws.onmessage = (ev) => { - if (typeof ev.data === 'string') { - if (ev.data.charCodeAt(0) === 123) { - try { - const json = JSON.parse(ev.data); - if (json.type === 'session-ended') { - setStatus('error', 'Session ended'); - term.writeln('\r\n\x1b[1;31m[Session ended]\x1b[0m'); - return; - } - } catch { /* not JSON */ } - } - term.write(ev.data); - } else { - term.write(new Uint8Array(ev.data)); - } - }; - - ws.onclose = () => { - if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } - setStatus('error', 'Disconnected'); - if (typeof App.onNetworkChange === 'function') App.onNetworkChange(); - const pending = connectResolvers.splice(0); - pending.forEach(r => r.reject(new Error('Disconnected'))); - }; - - ws.onerror = () => { - setStatus('error', 'Connection error'); - const pending = connectResolvers.splice(0); - pending.forEach(r => r.reject(new Error('Connection error'))); - }; - } - - function ensureConnected() { - if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve(); - if (!currentSession) return Promise.reject(new Error('No session')); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - connectResolvers = connectResolvers.filter(r => r !== entry); - reject(new Error('Connection timeout')); - }, 5000); - const entry = { - resolve() { clearTimeout(timer); resolve(); }, - reject(e) { clearTimeout(timer); reject(e); }, - }; - connectResolvers.push(entry); - if (!ws || ws.readyState !== WebSocket.CONNECTING) connect(currentSession, App.getCurrentServer()); - }); - } - - function disconnect() { - if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } - TerminalControls.stopScrolling(); - TerminalControls.exitScrollMode(); - TerminalTextInput.close(); - TerminalNotes.closeNotes(); - hideClaudeRemote(); - clearTuiActions(); - if (tuiPollInterval) { clearInterval(tuiPollInterval); tuiPollInterval = null; } - if (ws) { ws.onclose = null; ws.close(); ws = null; } - currentSession = null; - setStatus('', 'Disconnected'); - } - - async function checkClaudeStatus(sessionName, serverName) { - try { - const origin = serverName ? ServerManager.getOrigin(serverName) : ''; - const res = await fetch(`${origin}/api/sessions/${encodeURIComponent(sessionName)}/claude-status`); - const data = await res.json(); - if (data.remoteControlUrl) { - claudeRemoteUrl = data.remoteControlUrl; - document.getElementById('claude-remote-btn').style.display = 'flex'; - } - } catch { /* ignore */ } - } - - function hideClaudeRemote() { - claudeRemoteUrl = null; - document.getElementById('claude-remote-btn').style.display = 'none'; - } - - // ---------- tui.json Action Buttons ---------- - - async function loadTuiActions(sessionName, serverName) { - try { - const origin = serverName ? ServerManager.getOrigin(serverName) : ''; - const res = await fetch(`${origin}/api/sessions/${encodeURIComponent(sessionName)}`); - const data = await res.json(); - renderTuiActions(data.actions || [], serverName); - if (data.displayTitle) { - document.getElementById('terminal-session-name').textContent = data.displayTitle; - } - } catch { /* ignore */ } - } - - const TUI_ICONS = { - drupal: '', - comment: '', - }; - - function renderTuiActions(actions, serverName) { - const container = document.getElementById('tui-actions-container'); - container.innerHTML = ''; - for (const action of actions) { - const btn = document.createElement('button'); - btn.className = 'toolbar-icon-btn tui-action-btn'; - btn.title = action.label; - const icon = action.icon && TUI_ICONS[action.icon]; - if (icon) { - btn.innerHTML = icon; - } else { - btn.textContent = action.label; - } - btn.addEventListener('click', (e) => { - e.preventDefault(); e.stopPropagation(); - if (action.type === 'url') { - window.open(action.url, '_blank'); - } else if (action.type === 'file-open') { - const origin = serverName ? ServerManager.getOrigin(serverName) : ''; - FileEditor.open(action.path, origin); - } - }); - container.appendChild(btn); - } - } - - function clearTuiActions() { - const container = document.getElementById('tui-actions-container'); - if (container) container.innerHTML = ''; - } - - function setStatus(state, text) { - const dot = document.getElementById('terminal-status-dot'); - const label = document.getElementById('terminal-status-text'); - const reconnectBtn = document.getElementById('reconnect-btn'); - if (dot) dot.className = 'status-dot ' + (state === 'connected' ? 'attached' : state === 'connecting' ? 'detached' : ''); - if (label) label.textContent = text; - if (reconnectBtn) reconnectBtn.style.display = (state === 'error' && currentSession) ? 'flex' : 'none'; - } - - return { init, connect, disconnect }; -})(); diff --git a/public/manifest.json b/public/manifest.json deleted file mode 100644 index 17a4dc3..0000000 --- a/public/manifest.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "TUI Browser", - "short_name": "TUI", - "description": "VNC for terminals — control your terminal from any browser", - "start_url": "./", - "scope": "./", - "display": "standalone", - "background_color": "#000000", - "theme_color": "#000000", - "icons": [ - { - "src": "icons/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any" - }, - { - "src": "icons/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/public/readme_assets/session_info.png b/public/readme_assets/session_info.png index a3229fa..8c8d068 100644 Binary files a/public/readme_assets/session_info.png and b/public/readme_assets/session_info.png differ diff --git a/public/readme_assets/session_listing.png b/public/readme_assets/session_listing.png index 51525a3..4b00d71 100644 Binary files a/public/readme_assets/session_listing.png and b/public/readme_assets/session_listing.png differ diff --git a/public/readme_assets/temrinal_view.png b/public/readme_assets/temrinal_view.png index 4c58076..021b29c 100644 Binary files a/public/readme_assets/temrinal_view.png and b/public/readme_assets/temrinal_view.png differ diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index b03fe02..0000000 --- a/public/sw.js +++ /dev/null @@ -1,53 +0,0 @@ -const CACHE = 'tui-v2'; - -// Cache CDN deps on install (they're versioned, won't change) -const CDN_ASSETS = [ - 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css', - 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js', - 'https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js', - 'https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js', -]; - -self.addEventListener('install', (e) => { - e.waitUntil( - caches.open(CACHE).then((cache) => cache.addAll(CDN_ASSETS)) - ); - self.skipWaiting(); -}); - -self.addEventListener('activate', (e) => { - // Clean up old caches when a new SW version activates - e.waitUntil( - caches.keys().then((names) => - Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n))) - ).then(() => self.clients.claim()) - ); -}); - -self.addEventListener('fetch', (e) => { - const url = new URL(e.request.url); - - // Never cache API calls or WebSocket upgrades - if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws/')) return; - - // CDN assets: cache-first (they're versioned/immutable) - if (url.hostname.includes('cdn.jsdelivr.net') || url.hostname.includes('fonts.googleapis.com') || url.hostname.includes('fonts.gstatic.com')) { - e.respondWith( - caches.match(e.request).then((cached) => cached || fetch(e.request).then((res) => { - const clone = res.clone(); - caches.open(CACHE).then((cache) => cache.put(e.request, clone)); - return res; - })) - ); - return; - } - - // Local assets: network-first (always get latest, cache as fallback) - e.respondWith( - fetch(e.request).then((res) => { - const clone = res.clone(); - caches.open(CACHE).then((cache) => cache.put(e.request, clone)); - return res; - }).catch(() => caches.match(e.request)) - ); -}); diff --git a/scripts/tmux-kitty-shell b/scripts/tmux-kitty-shell deleted file mode 100755 index cba522c..0000000 --- a/scripts/tmux-kitty-shell +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -export LANG=en_IN.UTF-8 -export LC_ALL=en_IN.UTF-8 -exec tmux -u new-session -A -s "kitty-$$" diff --git a/server/discovery.js b/server/discovery.js deleted file mode 100644 index 289efe8..0000000 --- a/server/discovery.js +++ /dev/null @@ -1,202 +0,0 @@ -/** - * tmux session discovery — queries tmux for active sessions, windows, and panes. - */ - -const { exec } = require('./exec-util'); -const tuiOverrides = require('./tui-overrides'); -const state = require('./state'); - -async function isTmuxAvailable() { - try { - await exec('tmux', ['-V']); - return true; - } catch { - return false; - } -} - -async function isTmuxServerRunning() { - try { - await exec('tmux', ['list-sessions']); - return true; - } catch { - return false; - } -} - -const SEP = '|||'; - -const SESSION_FORMAT = [ - '#{session_id}', - '#{session_name}', - '#{session_windows}', - '#{session_attached}', - '#{session_created}', - '#{session_activity}', -].join(SEP); - -const PANE_FORMAT = [ - '#{pane_id}', - '#{pane_tty}', - '#{pane_pid}', - '#{pane_current_command}', - '#{pane_width}', - '#{pane_height}', - '#{pane_active}', - '#{pane_title}', - '#{pane_current_path}', -].join(SEP); - -const CLIENT_FORMAT = ['#{client_pid}', '#{session_name}'].join(SEP); - -async function listSessions() { - let raw; - try { - raw = await exec('tmux', ['list-sessions', '-F', SESSION_FORMAT]); - } catch { - return []; // no server or no sessions - } - - if (!raw) return []; - - const parsed = raw.split('\n').map((line) => { - const [id, name, windows, attached, created, activity] = line.split(SEP); - return { - id, - name, - windows: parseInt(windows, 10), - attached: parseInt(attached, 10), - created: parseInt(created, 10) * 1000, // ms epoch - lastActivity: parseInt(activity, 10) * 1000, - }; - }); - - const paneResults = await Promise.all(parsed.map((s) => listPanes(s.name))); - - return parsed.map((s, i) => ({ ...s, panes: paneResults[i] })); -} - -async function listPanes(sessionName) { - let raw; - try { - raw = await exec('tmux', ['list-panes', '-t', sessionName, '-F', PANE_FORMAT]); - } catch { - return []; - } - - if (!raw) return []; - - return raw.split('\n').map((line) => { - const [id, tty, pid, command, width, height, active, title, cwd] = line.split(SEP); - return { - id, - tty, - pid: parseInt(pid, 10), - command, - width: parseInt(width, 10), - height: parseInt(height, 10), - active: active === '1', - title: title || '', - cwd: cwd || '', - }; - }); -} - -async function capturePane(sessionName) { - try { - return await exec('tmux', ['capture-pane', '-t', sessionName, '-p']); - } catch { - return ''; - } -} - -async function getSessionDetail(sessionName) { - const sessions = await listSessions(); - const session = sessions.find((s) => s.name === sessionName); - if (!session) return null; - session.preview = await capturePane(sessionName); - return session; -} - -async function listTmuxClients() { - let raw; - try { - raw = await exec('tmux', ['list-clients', '-F', CLIENT_FORMAT]); - } catch { - return []; - } - - if (!raw) return []; - - return raw.split('\n').map((line) => { - const [pid, sessionName] = line.split(SEP); - return { pid: parseInt(pid, 10), sessionName }; - }); -} - -/** - * Unified discovery — returns tmux sessions + kitty windows in one call. - */ -async function discoverAll() { - const kittyDiscovery = require('./kitty-discovery'); - - const [tmuxSessions, tmuxClients, kittyResult] = await Promise.all([ - listSessions(), - listTmuxClients(), - kittyDiscovery.discoverKittyWindows(), - ]); - - // Map: client PID → session name (for Kitty matching) - const pidToSession = new Map(); - for (const client of tmuxClients) { - pidToSession.set(client.pid, client.sessionName); - } - - // Match Kitty windows to tmux sessions via PID - const kittyWindows = kittyResult.available ? kittyResult.windows || [] : []; - const matchedKittyBySession = new Map(); // sessionName → [kittyWindow, ...] - const unmatchedKitty = []; - - for (const win of kittyWindows) { - const sessionName = pidToSession.get(win.pid); - if (sessionName) { - if (!matchedKittyBySession.has(sessionName)) { - matchedKittyBySession.set(sessionName, []); - } - matchedKittyBySession.get(sessionName).push(win); - } else { - unmatchedKitty.push(win); - } - } - - // Build unified session objects - const sessions = tmuxSessions.map((s) => ({ - ...s, - kittyWindows: matchedKittyBySession.get(s.name) || [], - })); - - // tui.json overrides — auto-discover from origin CWD (or live pane cwd as fallback) - tuiOverrides.refreshTuiFiles(sessions, state.originCwds); - for (const s of sessions) { - const cwd = state.originCwds[s.name] || s.panes?.[0]?.cwd || ''; - const overrides = tuiOverrides.getTuiOverrides(s.name, cwd); - if (overrides) { - if (overrides.title) s.tuiTitle = overrides.title; - if (overrides.fileCwd) s.fileCwd = overrides.fileCwd; - if (overrides.actions) s.actions = overrides.actions; - } - } - - return { sessions, unmatchedKitty }; -} - -module.exports = { - isTmuxAvailable, - isTmuxServerRunning, - listSessions, - listPanes, - capturePane, - getSessionDetail, - listTmuxClients, - discoverAll, -}; diff --git a/server/index.js b/server/index.js deleted file mode 100644 index 969f60c..0000000 --- a/server/index.js +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env node -/** - * TUI Browser Server — HTTP + WebSocket orchestrator. - * Routes, state, and AI titles are in separate modules. - */ - -const path = require('path'); -const http = require('http'); -const express = require('express'); -const { WebSocketServer } = require('ws'); -const discovery = require('./discovery'); -const sessions = require('./session-manager'); -const kittyDiscovery = require('./kitty-discovery'); -const state = require('./state'); -const aiTitles = require('./ai-titles'); -const routes = require('./routes'); -const fileRoutes = require('./file-routes'); - -const PORT = parseInt(process.env.PORT || process.argv[2], 10) || 3000; -const BIND = (process.env.BIND || '').trim() || null; -const PKG_VERSION = require('../package.json').version; -const BUILD_ID = Date.now().toString(36); -const FULL_VERSION = `${PKG_VERSION.replace(/\.\d+$/, '')}.${BUILD_ID}`; - -// ---------- Express Setup ---------- - -const app = express(); -app.use(express.json({ limit: '2mb' })); - -// CORS — safe because only Tailscale devices can reach the server -app.use((req, res, next) => { - const origin = req.headers.origin; - if (origin) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - } - if (req.method === 'OPTIONS') return res.sendStatus(204); - next(); -}); - -// Security headers -app.use((req, res, next) => { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - if (req.path.startsWith('/api/')) res.setHeader('Cache-Control', 'no-store'); - next(); -}); - -// Cache control -app.use((req, res, next) => { - const p = req.path; - if (p === '/' || p.endsWith('.html') || p.endsWith('sw.js') || p.endsWith('manifest.json')) { - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - } - next(); -}); - -app.use(express.static(path.join(__dirname, '..', 'public'))); - -// ---------- Routes ---------- - -routes.setup(app, { - discovery, sessions, kittyDiscovery, state, aiTitles, - config: { PORT, FULL_VERSION, BUILD_ID }, -}); -fileRoutes.setup(app); -const serversConfig = require('./servers'); -serversConfig.setupRoutes(app); -const selfUpdate = require('./update'); -selfUpdate.setupRoutes(app); - -// ---------- HTTP ---------- - -const server = http.createServer(app); - -// ---------- WebSocket ---------- - -const wss = new WebSocketServer({ noServer: true }); - -function handleWsUpgrade(req, socket, head) { - const match = req.url.match(/^\/ws\/terminal\/(.+)$/); - if (!match) { socket.destroy(); return; } - wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, decodeURIComponent(match[1]))); -} - -server.on('upgrade', handleWsUpgrade); - -wss.on('connection', (ws, sessionName) => { - let cols = 80, rows = 24, attached = false; - - ws.on('message', (msg) => { - const str = msg.toString(); - if (attached && str.charCodeAt(0) !== 123) { sessions.writeInput(sessionName, str); return; } - try { - const json = JSON.parse(str); - if (json.type === 'resize' && json.cols && json.rows) { - cols = json.cols; rows = json.rows; - if (attached) sessions.resize(sessionName, cols, rows); - } else if (json.type === 'attach' && !attached) { - cols = json.cols || cols; rows = json.rows || rows; - sessions.attachClient(sessionName, ws, cols, rows); - attached = true; - } else if (json.type === 'input' && json.data != null && attached) { - sessions.writeInput(sessionName, json.data); - } - } catch { - if (attached) sessions.writeInput(sessionName, str); - } - }); - - ws.on('close', () => { if (attached) sessions.detachClient(sessionName, ws); }); -}); - -// ---------- Startup ---------- - -(async () => { - if (!(await discovery.isTmuxAvailable())) { - console.error('ERROR: tmux is not installed.'); - process.exit(1); - } - - const bindArgs = BIND ? [PORT, BIND] : [PORT]; - server.listen(...bindArgs, () => { - const addr = BIND || '0.0.0.0'; - console.log(`TUI Browser listening on http://${addr}:${PORT}`); - }); - - function gracefulShutdown(signal) { - console.log(`${signal} received — shutting down gracefully...`); - sessions.shutdown(); - wss.close(); - server.close(() => process.exit(0)); - const t = setTimeout(() => process.exit(1), 5000); - t.unref(); - } - process.on('SIGINT', () => gracefulShutdown('SIGINT')); - process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); -})(); diff --git a/server/kitty-discovery.js b/server/kitty-discovery.js deleted file mode 100644 index 7f788d6..0000000 --- a/server/kitty-discovery.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Kitty terminal discovery — uses `kitten @` remote control to discover - * Kitty windows/tabs, then connects via tmux for actual terminal I/O. - * - * Kitty remote control must be enabled in kitty.conf: - * allow_remote_control yes - * listen_on unix:/tmp/kitty-socket - */ - -const { exec } = require('./exec-util'); - -/** - * Check if kitten CLI is available. - */ -async function isKittyAvailable() { - try { - await exec('kitten', ['--version']); - return true; - } catch { - return false; - } -} - -/** - * Check if Kitty remote control is reachable. - * Tries the KITTY_LISTEN_ON socket first, then common socket paths. - */ -async function isKittyRemoteAvailable() { - const sockets = [ - process.env.KITTY_LISTEN_ON, - '/tmp/kitty-socket', - `/tmp/kitty-${process.env.USER || 'root'}`, - ].filter(Boolean); - - for (const sock of sockets) { - try { - await exec('kitten', ['@', '--to', `unix:${sock}`, 'ls']); - return { available: true, socket: sock }; - } catch { - // try next - } - } - - // Try without explicit socket (works if run from within a Kitty window) - try { - await exec('kitten', ['@', 'ls']); - return { available: true, socket: null }; - } catch { - return { available: false, socket: null }; - } -} - -/** - * Build the `kitten @` command args, including --to if we have a socket. - */ -function kittenArgs(socket, ...rest) { - const args = ['@']; - if (socket) args.push('--to', `unix:${socket}`); - args.push(...rest); - return args; -} - -/** - * List all Kitty OS windows, tabs, and inner windows. - * Returns the raw JSON structure from `kitten @ ls`. - */ -async function listKittyWindows(socket) { - try { - const raw = await exec('kitten', kittenArgs(socket, 'ls')); - return JSON.parse(raw); - } catch { - return []; - } -} - -/** - * Get text content from a specific Kitty window. - */ -async function getWindowText(socket, windowId) { - try { - const text = await exec('kitten', kittenArgs(socket, 'get-text', '--match', `id:${windowId}`)); - return text; - } catch { - return ''; - } -} - -/** - * Discover Kitty windows and normalize them into a flat list - * compatible with the dashboard display. - */ -async function discoverKittyWindows() { - const { available, socket } = await isKittyRemoteAvailable(); - if (!available) return { available: false, windows: [] }; - - const osWindows = await listKittyWindows(socket); - - // Flatten all windows — no preview fetching (tmux handles previews) - const windows = []; - for (const osWin of osWindows) { - for (const tab of osWin.tabs || []) { - for (const win of tab.windows || []) { - windows.push({ - id: win.id, - title: win.title || `Window ${win.id}`, - pid: win.pid, - cwd: win.cwd || '', - cmdline: (win.cmdline || []).join(' '), - isFocused: win.is_focused || false, - osWindowId: osWin.id, - tabId: tab.id, - tabTitle: tab.title || `Tab ${tab.id}`, - columns: win.columns, - lines: win.lines, - source: 'kitty', - }); - } - } - } - - return { available: true, socket, windows }; -} - -module.exports = { - isKittyAvailable, - isKittyRemoteAvailable, - discoverKittyWindows, - listKittyWindows, - getWindowText, -}; diff --git a/server/session-manager.js b/server/session-manager.js deleted file mode 100644 index 15e1839..0000000 --- a/server/session-manager.js +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Session manager — attaches to tmux sessions via node-pty, manages lifecycle. - * - * Each tmux session can have multiple web clients viewing/controlling it. - * When all web clients disconnect, the node-pty attachment is cleaned up - * but the tmux session keeps running. - */ - -const pty = require('node-pty'); -const { spawn } = require('child_process'); -const { exec } = require('./exec-util'); -const { isKittyRemoteAvailable } = require('./kitty-discovery'); -const state = require('./state'); - -// Locale env for tmux UTF-8 support (also in scripts/tmux-kitty-shell) -const LOCALE_ENV = { LANG: 'en_IN.UTF-8', LC_ALL: 'en_IN.UTF-8' }; - -// Cached Kitty socket path (resolved on first use) -let kittySocket = undefined; // undefined = not yet checked, null = unavailable - -/** - * Open a Kitty OS window attached to a tmux session. - * Uses kitten @ launch to join the existing Kitty instance (enables tab dragging). - * Falls back to spawning a new kitty process if remote control is unavailable. - */ -async function launchKittyWindow(name) { - // Discover socket on first call, cache result - if (kittySocket === undefined) { - try { - const result = await isKittyRemoteAvailable(); - kittySocket = result.available ? result.socket : null; - } catch { - kittySocket = null; - } - } - - // Try kitten @ launch (joins existing Kitty instance) - if (kittySocket) { - try { - const args = ['@', '--to', `unix:${kittySocket}`, 'launch', - '--type=os-window', 'tmux', '-u', 'attach-session', '-t', name]; - await exec('kitten', args); - return; - } catch (err) { - console.warn(`[session-manager] kitten @ launch failed, falling back to spawn:`, err.message); - } - } - - // Fallback: spawn a new kitty process - try { - const kittyProc = spawn('kitty', ['-e', 'tmux', '-u', 'attach-session', '-t', name], { - detached: true, - stdio: 'ignore', - env: { ...process.env, ...LOCALE_ENV }, - }); - kittyProc.on('error', (err) => { - console.warn(`[session-manager] Kitty launch failed for session ${name}:`, err.message); - }); - kittyProc.unref(); - } catch (err) { - console.warn(`[session-manager] Kitty launch failed for session ${name}:`, err.message); - } -} - -// sessionName → { proc: pty.IPty, clients: Set } -const activeSessions = new Map(); - -function closeClient(client, sessionName) { - try { - client.send(JSON.stringify({ type: 'session-ended', sessionName })); - client.close(); - } catch { /* ignore */ } -} - -/** - * Attach a WebSocket client to a tmux session. - * If this is the first client, spawns a node-pty `tmux attach`. - * Subsequent clients share the same pty and receive broadcast output. - */ -function attachClient(sessionName, ws, cols = 80, rows = 24) { - let entry = activeSessions.get(sessionName); - - if (!entry) { - const proc = pty.spawn('tmux', ['-u', 'attach-session', '-t', sessionName], { - name: 'xterm-256color', - cols, - rows, - cwd: process.env.HOME, - env: { ...process.env, TERM: 'xterm-256color', ...LOCALE_ENV }, - }); - - entry = { proc, clients: new Set() }; - activeSessions.set(sessionName, entry); - - proc.onData((data) => { - for (const client of entry.clients) { - try { - client.send(data); - } catch { - // client may have closed - } - } - }); - - proc.onExit(() => { - for (const client of entry.clients) closeClient(client, sessionName); - activeSessions.delete(sessionName); - }); - } - - entry.clients.add(ws); - - // Resize to match this client (last client wins on size) - if (cols && rows) { - entry.proc.resize(cols, rows); - } - - return entry; -} - -/** - * Write terminal input from a web client to the pty. - */ -function writeInput(sessionName, data) { - const entry = activeSessions.get(sessionName); - if (entry) { - entry.proc.write(data); - } -} - -/** - * Resize the pty for a session. - */ -function resize(sessionName, cols, rows) { - const entry = activeSessions.get(sessionName); - if (entry) { - entry.proc.resize(cols, rows); - } -} - -/** - * Detach a web client. If no clients remain, kill the pty attachment - * (the tmux session itself keeps running). - */ -function detachClient(sessionName, ws) { - const entry = activeSessions.get(sessionName); - if (!entry) return; - - entry.clients.delete(ws); - - if (entry.clients.size === 0) { - entry.proc.kill(); - activeSessions.delete(sessionName); - } -} - -/** - * Create a new tmux session. - */ -async function createSession(name, command = 'bash', cols = 80, rows = 24, cwd, { openKitty = true } = {}) { - const args = ['-u', 'new-session', '-d', '-s', name, '-x', String(cols), '-y', String(rows)]; - if (cwd) { - args.push('-c', cwd); - } - await exec('tmux', args); - - // Record the origin CWD (immutable — used for tui.json discovery) - try { - const initialCwd = await exec('tmux', ['display', '-t', name, '-p', '#{pane_current_path}']); - const trimmed = initialCwd.trim(); - if (trimmed) { - state.originCwds[name] = trimmed; - state.saveOriginCwds(); - } - } catch { /* best effort */ } - - // Send the command as keystrokes so the session keeps a live shell - if (command && command !== 'bash') { - await exec('tmux', ['send-keys', '-t', name, command, 'Enter']); - } - - // Best-effort: open a Kitty window attached to this session - if (openKitty) { - launchKittyWindow(name); - } - - return { name, command }; -} - -/** - * Open a Kitty window attached to an existing tmux session. - */ -function openTerminal(name) { - launchKittyWindow(name); -} - -/** - * Kill a tmux session. - */ -async function killSession(name) { - // Clean up any active pty attachment first - const entry = activeSessions.get(name); - if (entry) { - for (const client of entry.clients) closeClient(client, name); - entry.proc.kill(); - activeSessions.delete(name); - } - - await exec('tmux', ['kill-session', '-t', name]); -} - -/** - * Rename a tmux session. - */ -async function renameSession(oldName, newName) { - await exec('tmux', ['rename-session', '-t', oldName, newName]); -} - -/** - * Get the number of connected web clients for a session. - */ -function getClientCount(sessionName) { - const entry = activeSessions.get(sessionName); - return entry ? entry.clients.size : 0; -} - -/** - * Graceful shutdown — close all PTY attachments and notify clients. - * Does NOT kill tmux sessions (they survive server restarts by design). - */ -function shutdown() { - for (const [sessionName, entry] of activeSessions) { - for (const client of entry.clients) closeClient(client, sessionName); - try { entry.proc.kill(); } catch { /* ignore */ } - } - activeSessions.clear(); -} - -module.exports = { - attachClient, - writeInput, - resize, - detachClient, - createSession, - killSession, - renameSession, - openTerminal, - getClientCount, - shutdown, -};