merge: integrate upstream (clipboard, URL click, search addon, delete fixes) with federation proxy rewrite

This commit is contained in:
Brian Krabach
2026-04-01 18:55:54 -07:00
16 changed files with 869 additions and 101 deletions
+163 -83
View File
@@ -1,6 +1,6 @@
# muxplex
**Web-based tmux session dashboard — access and manage all your tmux sessions from any browser or mobile device.**
**Web-based tmux session dashboard — access, monitor, and manage all your tmux sessions from any browser on any device.**
![muxplex dashboard](assets/branding/og/og-dark.png)
@@ -8,12 +8,51 @@
## Features
- **Live session grid** — thumbnail snapshots of every running tmux session, auto-refreshed
- **Full interactive terminal** — click any session to open a real terminal (powered by ttyd + xterm.js)
- **Collapsible session sidebar** — quick-switch between sessions without leaving the terminal view
- **Bell & activity notifications** — visual alerts when any session rings a bell or has new output
- **Mobile-friendly responsive layout** — works on phones and tablets; PWA-capable for home-screen install
- **Works over Tailscale / private network** — serve to any device on your network without exposing to the internet
### Dashboard
- **Live session grid** — preview tiles with ANSI-colored terminal snapshots, auto-refreshed
- **Two view modes** — Auto (scrollable grid) and Fit (all sessions fill the viewport)
- **Hover preview** — full-size overlay of session content on tile hover
- **Activity indicators** — bell notification badges, amber favicon dot for browser tab visibility
- **Session creation** — `+` button with custom command template support
- **Session deletion** — `×` button with custom command template support
- **Mobile-friendly** — responsive layout, PWA-capable for home-screen install
### Terminal
- **Full interactive terminal** — powered by xterm.js + ttyd
- **Native clipboard** — Ctrl+Shift+C to copy, Ctrl+Shift+V to paste
- **Mouse select auto-copy** — selecting text copies to system clipboard on release
- **OSC 52 tmux clipboard bridge** — tmux copy mode selections go to system clipboard
- **Sidebar session switcher** — quick-switch between sessions with live previews
### Settings
- **In-browser settings panel** — gear icon or `,` shortcut
- **Display** — font size, grid columns, hover delay, view mode, device badges, activity indicator
- **Sessions** — default session, sort order, hidden sessions, auto-open, bell sound, notifications
- **Commands** — custom create/delete session templates
- **Multi-Device** — remote instance federation
- **CLI** — `muxplex config list/get/set/reset`
### Service Management
- `muxplex service install/start/stop/restart/status/logs/uninstall`
- **Platform-aware** — systemd user service on Linux/WSL, launchd agent on macOS
- **Config-driven** — service reads all options from `~/.config/muxplex/settings.json` (no flags in the service file)
### Authentication
- **PAM authentication** — Linux/macOS system credentials
- **Password mode** — auto-generated or set via `MUXPLEX_PASSWORD` env var
- **Localhost bypass** — no auth needed on 127.0.0.1
- **Secure session cookies** — signed with configurable TTL
### Developer Tools
- `muxplex doctor` — dependency + config diagnostics with update check
- `muxplex upgrade` — smart version check + auto-update + service restart
- `muxplex config` — CLI settings management
---
@@ -28,7 +67,7 @@
- Ubuntu/WSL: `sudo apt install ttyd` or `sudo snap install ttyd`
- Other: https://github.com/tsl0922/ttyd#installation
> **Tip:** muxplex checks for `tmux` and `ttyd` at startup and prints install instructions if either is missing.
> **Tip:** Run `muxplex doctor` to check all dependencies and system status.
---
@@ -48,10 +87,9 @@ Then open **http://localhost:8088** in your browser.
## Install Permanently
Install muxplex as a persistent CLI tool using `uv tool`:
```bash
uv tool install git+https://github.com/bkrabach/muxplex
muxplex doctor # verify dependencies
```
Then run it any time with:
@@ -66,49 +104,43 @@ muxplex
```bash
muxplex service install
# → prompts to set host to 0.0.0.0 for network access
```
The service starts automatically on login (macOS) or at boot (Linux) and restarts on failure.
```bash
# Open in browser
open http://localhost:8088
```
To stop and remove:
```bash
muxplex service uninstall
```
> **Note:** All service commands use the `muxplex service` subcommand — see [Service management](#service-management) below.
---
## Usage
## CLI Reference
```bash
muxplex [OPTIONS]
muxplex serve [OPTIONS] # explicit form
```
All serve options read from `~/.config/muxplex/settings.json` by default. CLI flags override for that run only.
| Option | settings.json key | Default | Description |
|---|---|---|---|
| `--host HOST` | `host` | `127.0.0.1` | Interface to bind (`0.0.0.0` for network access) |
| `--port PORT` | `port` | `8088` | Port to listen on |
| `--auth MODE` | `auth` | `pam` | Auth method: `pam` or `password` |
| `--session-ttl SEC` | `session_ttl` | `604800` | Session TTL in seconds (7 days; 0 = browser session) |
### Other commands
| Command | Description |
|---|---|
| `muxplex doctor` | Check dependencies and system status |
| `muxplex upgrade` | Upgrade to latest version and restart service |
| `muxplex show-password` | Show the current muxplex password |
| `muxplex reset-secret` | Regenerate signing secret (invalidates sessions) |
| `muxplex config` | Show all settings with current values |
| `muxplex config list` | Show all settings with current values |
| `muxplex config get <key>` | Show one setting |
| `muxplex config set <key> <value>` | Set a setting (auto-detects type) |
| `muxplex config reset [key]` | Reset one or all settings to defaults |
muxplex Start server (default)
muxplex serve [flags] Start with CLI flag overrides
muxplex service install Install + enable + start as OS service
muxplex service uninstall Stop + disable + remove
muxplex service start|stop|restart Manage running service
muxplex service status Show service status
muxplex service logs Tail service logs
muxplex config Show all settings
muxplex config get <key> Show one setting
muxplex config set <key> <value> Set a setting
muxplex config reset [key] Reset one or all to defaults
muxplex upgrade [--force] Smart update with version check
muxplex doctor Check dependencies + config
muxplex show-password Show current auth password
muxplex reset-secret Regenerate signing secret
```
### Service management
@@ -122,11 +154,10 @@ muxplex service status # Show running/stopped + PID
muxplex service logs # Tail service logs
```
The service runs `muxplex serve` with no flags — it reads all options from `~/.config/muxplex/settings.json`. To change host/port, edit the config and restart:
The service runs `muxplex serve` with no flags — it reads all options from `~/.config/muxplex/settings.json`. To change host/port, edit the config (or use the Settings UI in the browser) and restart:
```bash
# Edit settings to bind to all interfaces
# (or use the Settings UI in the browser)
muxplex config set host 0.0.0.0
muxplex service restart
```
@@ -145,6 +176,89 @@ muxplex serve --host 0.0.0.0
---
## Configuration
All settings are stored in `~/.config/muxplex/settings.json`.
| Key | Default | Description |
|---|---|---|
| `host` | `127.0.0.1` | Bind address (set to `0.0.0.0` for network access) |
| `port` | `8088` | Server port |
| `auth` | `pam` | Authentication mode: `pam` or `password` |
| `session_ttl` | `604800` | Session cookie TTL in seconds (7 days; 0 = browser session) |
| `default_session` | `null` | Session to auto-open on load |
| `sort_order` | `manual` | Session ordering: `manual`, `alphabetical`, `recent` |
| `hidden_sessions` | `[]` | Sessions hidden from the dashboard |
| `window_size_largest` | `false` | Auto-set tmux `window-size largest` on connect |
| `auto_open_created` | `true` | Auto-open newly created sessions |
| `new_session_template` | `tmux new-session -d -s {name}` | Command template for creating sessions |
| `delete_session_template` | `tmux kill-session -t {name}` | Command template for deleting sessions |
| `device_name` | `""` (hostname) | Display name for this device |
| `remote_instances` | `[]` | Remote muxplex instances to aggregate |
| `multi_device_enabled` | `false` | Enable multi-instance federation |
**Priority:** CLI flags > `settings.json` > defaults.
---
## Keyboard Shortcuts
| Shortcut | Action |
|---|---|
| Ctrl+Shift+C | Copy terminal selection to system clipboard |
| Ctrl+Shift+V | Paste from system clipboard into terminal |
| `,` (comma) | Open settings |
| Escape | Close settings / return to dashboard |
Mouse select in the terminal auto-copies to the system clipboard on release.
---
## Platform Support
| Platform | Service | Auth |
|---|---|---|
| Linux (Ubuntu/Debian) | systemd user service | PAM |
| macOS | launchd agent | PAM |
| WSL | systemd user service | PAM |
---
## Project Structure
```
muxplex/
├── muxplex/
│ ├── __init__.py
│ ├── __main__.py # python -m muxplex entry
│ ├── cli.py # CLI entry point and subcommand dispatch
│ ├── main.py # FastAPI app, routes, WebSocket proxy
│ ├── auth.py # PAM/password auth middleware
│ ├── sessions.py # tmux session enumeration + snapshots
│ ├── bells.py # Bell flag detection + clear rules
│ ├── state.py # Persistent state (JSON)
│ ├── settings.py # User settings management
│ ├── service.py # Service install/start/stop (systemd + launchd)
│ ├── ttyd.py # ttyd process lifecycle
│ ├── frontend/
│ │ ├── index.html # Main SPA
│ │ ├── login.html # Login page
│ │ ├── app.js # Dashboard, sidebar, settings, previews
│ │ ├── terminal.js # xterm.js terminal + clipboard
│ │ ├── style.css # All styles (dark theme)
│ │ ├── manifest.json # PWA manifest
│ │ ├── wordmark-on-dark.svg
│ │ └── tests/ # JavaScript unit tests
│ └── tests/ # Python tests (pytest)
├── assets/branding/ # Logos, icons, design system
├── docs/plans/ # Historical design + implementation plans
├── scripts/ # Utility scripts (asset generation)
├── pyproject.toml
└── README.md
```
---
## Development
### Setup
@@ -169,7 +283,7 @@ python -m muxplex
```bash
# Python tests (pytest)
pytest
python -m pytest muxplex/tests/ --ignore=muxplex/tests/test_integration.py
# JavaScript tests (node:test)
node --test muxplex/frontend/tests/test_terminal.mjs
@@ -178,46 +292,6 @@ node --test muxplex/frontend/tests/test_app.mjs
---
## Architecture
```
muxplex/
├── pyproject.toml # Package metadata, entry points, dependencies
├── README.md # This file
├── scripts/ # Utility scripts (asset generation, etc.)
│ └── render-brand-assets.py
├── assets/
│ └── branding/ # Brand design system and generated assets
│ ├── DESIGN-SYSTEM.md
│ ├── tokens.json / tokens.css
│ ├── svg/ # Source SVG files
│ ├── og/ # Open Graph images (og-dark.png, og-light.png)
│ ├── icons/ # App icons
│ ├── favicons/ # Favicon variants
│ ├── pwa/ # PWA manifest icons
│ ├── lockup/ # Wordmark + icon lockup
│ └── wordmark/ # Text-only wordmark
└── muxplex/ # Python package
├── __init__.py
├── __main__.py # `python -m muxplex` entry point
├── cli.py # CLI entry point and subcommand dispatch
├── main.py # FastAPI app: session API, bell hooks, WebSocket proxy to ttyd, static frontend
├── sessions.py # tmux session discovery and snapshot capture
├── bells.py # Bell/activity notification tracking
├── ttyd.py # ttyd process lifecycle management (spawn, kill, PID tracking)
├── state.py # Shared in-process state (sessions, bells, ttyd)
├── frontend/ # Static frontend assets (served as package data)
│ ├── index.html
│ ├── app.js
│ ├── terminal.js # xterm.js + WebSocket terminal init
│ ├── style.css
│ ├── manifest.json # PWA manifest
│ └── tests/ # JavaScript unit tests
└── tests/ # Python tests (pytest)
```
---
## Brand Assets
Design language, color tokens, and brand assets live in `assets/branding/`. See [`assets/branding/DESIGN-SYSTEM.md`](assets/branding/DESIGN-SYSTEM.md) for the full design reference.
@@ -227,3 +301,9 @@ To regenerate PNG/favicon assets from SVG sources:
```bash
python3 scripts/render-brand-assets.py
```
---
## License
MIT
+5
View File
@@ -0,0 +1,5 @@
# Implementation Plans (Historical)
These are historical design and implementation plan documents from the initial build of muxplex. All plans have been fully implemented. They are retained as architectural decision records (ADRs) and build logs.
See the main [README.md](../../README.md) for current documentation.
+1 -1
View File
@@ -191,7 +191,7 @@ def serve(
from muxplex.main import app # noqa: PLC0415
print(f" muxplex → http://{host}:{port}")
uvicorn.run(app, host=host, port=port, log_level="warning")
uvicorn.run(app, host=host, port=port, log_level="info")
def doctor() -> None:
+12 -2
View File
@@ -581,7 +581,9 @@ function renderSidebar(sessions, currentSession) {
list.querySelectorAll('.sidebar-item').forEach((item) => {
const name = item.dataset.session;
const remoteId = item.dataset.remoteId || '';
on(item, 'click', () => {
on(item, 'click', (e) => {
// Don't navigate when clicking the delete button inside the item
if (e.target.closest && e.target.closest('.sidebar-delete')) return;
if (name !== currentSession) openSession(name, { remoteId });
});
});
@@ -833,7 +835,11 @@ function renderGrid(sessions) {
// Bind interaction handlers on each tile
document.querySelectorAll('.session-tile').forEach(function(tile) {
on(tile, 'click', () => openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' }));
on(tile, 'click', (e) => {
// Don't navigate when clicking the delete button inside the tile
if (e.target.closest && e.target.closest('.tile-delete')) return;
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
});
on(tile, 'keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
@@ -2031,6 +2037,10 @@ function killSession(name) {
api('DELETE', '/api/sessions/' + name)
.then(function() {
showToast('Session \'' + name + '\' killed');
// If we deleted the session we're currently viewing, return to dashboard
if (_viewingSession === name) {
closeSession();
}
pollSessions();
})
.catch(function(err) {
+13 -1
View File
@@ -50,7 +50,16 @@
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
</div>
</div>
<div id="terminal-container" class="terminal-container"></div>
<div class="terminal-wrapper">
<div id="terminal-search-bar" class="terminal-search-bar hidden">
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
<span id="terminal-search-count" class="terminal-search-count"></span>
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)">&#9650;</button>
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">&#9660;</button>
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">&times;</button>
</div>
<div id="terminal-container" class="terminal-container"></div>
</div>
</div>
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting&hellip;</div>
</div>
@@ -233,6 +242,9 @@
<!-- ── Scripts ──────────────────────────────────────────────────────────── -->
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-search@0.13.0/lib/xterm-addon-search.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-image@0.9.0/lib/addon-image.js"></script>
<script src="/app.js" defer></script>
<script src="/terminal.js" defer></script>
</body>
+61
View File
@@ -630,6 +630,14 @@ body {
text-align: center;
}
.terminal-wrapper {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.terminal-container {
flex: 1;
min-width: 0;
@@ -638,6 +646,59 @@ body {
padding: 0 4px; /* keep text off the side edges */
}
/* Terminal search bar */
.terminal-search-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.terminal-search-bar.hidden {
display: none;
}
.terminal-search-input {
flex: 1;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 8px;
font-family: var(--font-ui);
font-size: 13px;
outline: none;
}
.terminal-search-input:focus {
border-color: var(--accent);
}
.terminal-search-count {
color: var(--text-muted);
font-size: 12px;
min-width: 40px;
text-align: center;
}
.terminal-search-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
padding: 2px 8px;
font-size: 12px;
}
.terminal-search-btn:hover {
color: var(--text);
border-color: var(--accent);
}
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
.xterm .xterm-viewport {
overflow-y: hidden !important;
+210 -10
View File
@@ -9,6 +9,40 @@ let _reconnectTimer = null;
let _currentSession = null;
let _vpHandler = null;
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
let _searchAddon = null;
// ─── Module-level encoding helpers ──────────────────────────────────────────
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
function _encodePayload(typeChar, str) {
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
var payload = new Uint8Array(1 + strBytes.length);
payload[0] = typeChar;
payload.set(strBytes, 1);
return payload;
}
// ─── Clipboard helpers ───────────────────────────────────────────────────────
// Ctrl+Shift+C: copy terminal selection to system clipboard
// Ctrl+Shift+V: paste from system clipboard into terminal
function _copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function() {});
} else {
// Fallback for non-HTTPS contexts (HTTP over LAN)
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch(e) {}
document.body.removeChild(ta);
}
}
// ─── Forward declarations ─────────────────────────────────────────────────────
@@ -26,16 +60,8 @@ function connectWebSocket(name, remoteId) {
url = proto + '//' + location.host + '/terminal/ws';
}
const reconnectOverlay = document.getElementById('reconnect-overlay');
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
function encodePayload(typeChar, str) {
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
var strBytes = encoder ? encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
var payload = new Uint8Array(1 + strBytes.length);
payload[0] = typeChar;
payload.set(strBytes, 1);
return payload;
}
// Use module-level _encodePayload (hoisted above connectWebSocket)
var encodePayload = _encodePayload;
// Register terminal event handlers once on this _term instance.
// These handlers read the module-level _ws at call time (not a captured reference),
@@ -221,6 +247,67 @@ function createTerminal() {
_fitAddon = new window.FitAddon.FitAddon();
_term.loadAddon(_fitAddon);
// Clickable URLs — Ctrl+Click (Windows/Linux) or Cmd+Click (macOS) opens in new tab.
// xterm-addon-web-links auto-detects URLs and adds hover underlines.
// Plain click is preserved for normal terminal text selection.
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
if (WebLinksAddon) {
_term.loadAddon(new WebLinksAddon(function(event, uri) {
if (event.ctrlKey || event.metaKey) {
window.open(uri, '_blank');
}
}));
}
// Search addon — Ctrl+F to find text in terminal buffer
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
if (SearchAddon) {
_searchAddon = new SearchAddon();
_term.loadAddon(_searchAddon);
}
// Image addon — inline image rendering (Sixel, iTerm2 IIP, Kitty graphics)
// Needed for tools like yazi file manager that use graphic protocols
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
if (ImageAddon) {
_term.loadAddon(new ImageAddon());
}
}
// ─── Search helpers ──────────────────────────────────────────────────────────────────────────────────────────────────
function _openSearch() {
var bar = document.getElementById('terminal-search-bar');
var input = document.getElementById('terminal-search-input');
if (bar) {
bar.classList.remove('hidden');
if (input) {
input.focus();
input.select();
}
}
}
function _closeSearch() {
var bar = document.getElementById('terminal-search-bar');
if (bar) bar.classList.add('hidden');
if (_searchAddon) _searchAddon.clearDecorations();
if (_term) _term.focus();
}
function _searchNext() {
var input = document.getElementById('terminal-search-input');
if (input && input.value && _searchAddon) {
_searchAddon.findNext(input.value);
}
}
function _searchPrev() {
var input = document.getElementById('terminal-search-input');
if (input && input.value && _searchAddon) {
_searchAddon.findPrevious(input.value);
}
}
// ─── Open / close ─────────────────────────────────────────────────────────────
@@ -262,6 +349,70 @@ function openTerminal(sessionName, remoteId) {
_term.open(container);
// --- Clipboard integration ---
// Ctrl+Shift+C: copy selection to system clipboard (Ctrl+C still sends SIGINT)
// Ctrl+Shift+V: paste from system clipboard (Ctrl+V still sends literal bytes)
_term.attachCustomKeyEventHandler(function(e) {
if (e.type !== 'keydown') return true;
// Ctrl+Shift+C → copy selection to clipboard
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
var sel = _term.getSelection();
if (sel) _copyToClipboard(sel);
return false; // prevent xterm from processing
}
// Ctrl+Shift+V → paste from clipboard into terminal
if (e.ctrlKey && e.shiftKey && (e.key === 'V' || e.code === 'KeyV')) {
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(function(text) {
if (text && _ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(_encodePayload(0x30, text));
}
}).catch(function() {});
}
return false; // prevent xterm from processing
}
// Ctrl+F → open search bar
if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
_openSearch();
return false;
}
return true; // let xterm handle all other keys normally
});
// Auto-copy: when mouse selection ends, copy to system clipboard.
// Matches terminal emulator conventions (iTerm2, WezTerm, ttyd native).
// onSelectionChange fires whenever selection changes — copy if text is selected.
// When selection is cleared (empty string), we skip the clipboard write.
_term.onSelectionChange(function() {
var sel = _term.getSelection();
if (sel) {
_copyToClipboard(sel);
}
});
// OSC 52 clipboard integration — bridges tmux clipboard to the browser.
// When tmux copies text (with `set-clipboard on` in .tmux.conf), it sends
// an OSC 52 escape sequence to the terminal. xterm.js surfaces this via the
// parser API. We intercept and write the decoded text to the system clipboard
// so that: Ctrl+B [ → select → Enter (tmux copy) → system clipboard receives it.
_term.parser.registerOscHandler(52, function(data) {
// OSC 52 format: Pc ; Pd — Pc = selection target (c/p/q/s/0-7), Pd = base64 text
var parts = data.split(';');
if (parts.length >= 2) {
try {
var text = atob(parts[1]);
_copyToClipboard(text);
} catch (e) {
// Invalid base64 or unsupported — silently ignore
}
}
return true; // Handled — don't pass to xterm's default handler
});
if (_fitAddon) {
// requestAnimationFrame guarantees one full browser layout pass after the flex
// container becomes visible before fit() measures dimensions.
@@ -279,6 +430,51 @@ function openTerminal(sessionName, remoteId) {
});
}
// Wire search bar buttons + keyboard handlers (idempotent — elements are static)
var searchInput = document.getElementById('terminal-search-input');
var searchClose = document.getElementById('terminal-search-close');
var searchNextBtn = document.getElementById('terminal-search-next');
var searchPrevBtn = document.getElementById('terminal-search-prev');
if (searchInput) {
// Remove old listeners by replacing with cloned element (avoids duplicate handlers on reconnect)
var newInput = searchInput.cloneNode(true);
searchInput.parentNode.replaceChild(newInput, searchInput);
searchInput = newInput;
searchInput.addEventListener('input', function() {
if (_searchAddon && searchInput.value) {
_searchAddon.findNext(searchInput.value);
} else if (_searchAddon) {
_searchAddon.clearDecorations();
}
});
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) _searchPrev(); else _searchNext();
}
if (e.key === 'Escape') {
e.preventDefault();
_closeSearch();
}
});
}
if (searchClose) {
var newClose = searchClose.cloneNode(true);
searchClose.parentNode.replaceChild(newClose, searchClose);
newClose.addEventListener('click', _closeSearch);
}
if (searchNextBtn) {
var newNext = searchNextBtn.cloneNode(true);
searchNextBtn.parentNode.replaceChild(newNext, searchNextBtn);
newNext.addEventListener('click', _searchNext);
}
if (searchPrevBtn) {
var newPrev = searchPrevBtn.cloneNode(true);
searchPrevBtn.parentNode.replaceChild(newPrev, searchPrevBtn);
newPrev.addEventListener('click', _searchPrev);
}
connectWebSocket(sessionName, remoteId);
initVisualViewport(); /* defined in Task 14 */
}
@@ -306,8 +502,10 @@ function closeTerminal() {
_term.dispose();
_term = null;
_fitAddon = null;
_searchAddon = null;
}
_closeSearch();
_currentSession = null;
_reconnectAttempts = 0; // reset backoff on intentional close
}
@@ -315,6 +513,8 @@ function closeTerminal() {
// ─── Expose to app.js ─────────────────────────────────────────────────────────
window._openTerminal = openTerminal;
window._closeTerminal = closeTerminal;
window._openSearch = _openSearch;
window._closeSearch = _closeSearch;
// ---------------------------------------------------------------------------
// setTerminalFontSize — live font-size update without reconnecting
+27
View File
@@ -3894,3 +3894,30 @@ test('remote instance debounced input listener selector includes .settings-remot
'debounced input listener on #setting-remote-instances must include .settings-remote-key so key-only edits trigger _saveRemoteInstances()',
);
});
// --- Bug fixes: delete UX ---
test('killSession closes active session and returns to dashboard', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
// Find killSession function body
const fnStart = source.indexOf('function killSession');
const fnBody = source.substring(fnStart, fnStart + 500);
assert.ok(fnBody.includes('_viewingSession'), 'killSession must check if deleted session is the active one');
assert.ok(fnBody.includes('closeSession'), 'killSession must call closeSession when deleting the active session');
});
test('sidebar click handler ignores clicks on delete button', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
assert.ok(
source.includes("closest('.sidebar-delete')"),
"sidebar click handler must guard against clicks on .sidebar-delete button"
);
});
test('tile click handler ignores clicks on tile-delete button', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
assert.ok(
source.includes("closest('.tile-delete')"),
"tile click handler must guard against clicks on .tile-delete button"
);
});
+76
View File
@@ -47,6 +47,10 @@ function loadTerminal() {
dispose: () => {},
write: (data) => { termWriteMessages.push(data); },
focus: () => { focusCallCount++; },
attachCustomKeyEventHandler: () => {},
getSelection: () => '',
onSelectionChange: () => {},
parser: { registerOscHandler: () => {} },
};
// Capture all messages sent via WebSocket.send()
@@ -331,6 +335,10 @@ function createMultiSessionEnv() {
loadAddon: () => {},
dispose: () => {},
focus: () => {},
attachCustomKeyEventHandler: () => {},
getSelection: () => '',
onSelectionChange: () => {},
parser: { registerOscHandler: () => {} },
writeMessages: [],
};
t.write = (data) => t.writeMessages.push(data);
@@ -891,6 +899,17 @@ test('terminal.js resets _reconnectAttempts on first message, not on open', () =
);
});
// --- Clipboard integration ---
test('terminal.js has clipboard integration with Ctrl+Shift+C and Ctrl+Shift+V', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(source.includes('attachCustomKeyEventHandler'), 'must register custom key handler');
assert.ok(source.includes('getSelection'), 'must use getSelection() for copy');
assert.ok(source.includes('clipboard'), 'must interact with clipboard API');
assert.ok(source.includes('Shift'), 'must use Shift modifier to avoid conflict with terminal Ctrl+C/V');
assert.ok(source.includes('_copyToClipboard') || source.includes('writeText'), 'must have copy mechanism');
});
// --- Issue 4: setTerminalFontSize ---
test('terminal.js exposes window._setTerminalFontSize function', () => {
@@ -915,4 +934,61 @@ test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()
);
});
// --- Clipboard Issue 1: auto-copy mouse selection via onSelectionChange ---
test('terminal.js auto-copies mouse selection to clipboard via onSelectionChange', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(
source.includes('onSelectionChange'),
'must register onSelectionChange handler to auto-copy mouse selection to clipboard',
);
});
// --- Clipboard Issue 2: OSC 52 handler bridges tmux clipboard to browser ---
test('terminal.js registers OSC 52 handler for tmux clipboard bridge', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(
source.includes('registerOscHandler'),
'must call parser.registerOscHandler to intercept tmux OSC 52 clipboard sequences',
);
assert.ok(
source.includes('atob'),
'must decode base64 OSC 52 clipboard payload with atob()',
);
});
// --- Clickable URLs via xterm-addon-web-links ---
test('terminal.js loads xterm-addon-web-links for clickable URLs', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(source.includes('WebLinksAddon'), 'must reference WebLinksAddon');
assert.ok(
source.includes('ctrlKey') || source.includes('metaKey'),
'must check modifier key for link clicks',
);
assert.ok(source.includes('window.open'), 'must open URLs in new tab');
});
// --- Search addon (xterm-addon-search) ---
test('terminal.js loads xterm-addon-search', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(source.includes('SearchAddon'), 'must reference SearchAddon');
assert.ok(source.includes('findNext') || source.includes('findPrevious'), 'must have search functions');
});
test('terminal.js has Ctrl+F search shortcut', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(source.includes('_openSearch'), 'must have search open function');
assert.ok(source.includes('_closeSearch'), 'must have search close function');
});
// --- Image addon (xterm-addon-image) ---
test('terminal.js loads xterm-addon-image for inline graphics', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(source.includes('ImageAddon'), 'must reference ImageAddon');
});
+7 -1
View File
@@ -366,6 +366,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
name = payload.name
settings = load_settings()
command = settings["new_session_template"].replace("{name}", name)
_log.info("Creating session '%s' with command: %s", name, command)
try:
subprocess.Popen(
command,
@@ -392,6 +393,7 @@ async def connect_session(name: str) -> dict:
if known and name not in known:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
_log.info("Connecting to session '%s'", name)
await kill_ttyd()
await spawn_ttyd(name)
@@ -442,15 +444,19 @@ async def delete_session(name: str) -> dict:
"delete_session_template", "tmux kill-session -t {name}"
).replace("{name}", name)
_log.info("Deleting session '%s' with command: %s", name, command)
try:
result = subprocess.run(
command,
shell=True,
input="y\n", # auto-confirm interactive prompts (e.g. amplifier-dev --destroy)
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
if result.returncode == 0:
_log.info("Session '%s' deleted successfully", name)
else:
_log.warning(
"Delete command failed (rc=%d): %s",
result.returncode,
+8 -2
View File
@@ -152,7 +152,10 @@ def _systemd_status() -> None:
def _systemd_logs() -> None:
subprocess.run(["journalctl", "--user", "-u", "muxplex", "-f"], check=True)
try:
subprocess.run(["journalctl", "--user", "-u", "muxplex", "-f"])
except KeyboardInterrupt:
pass
# ---------------------------------------------------------------------------
@@ -205,7 +208,10 @@ def _launchd_status() -> None:
def _launchd_logs() -> None:
subprocess.run(["tail", "-f", "/tmp/muxplex.log"], check=True)
try:
subprocess.run(["tail", "-f", "/tmp/muxplex.log"])
except KeyboardInterrupt:
pass
# ---------------------------------------------------------------------------
+137
View File
@@ -1914,3 +1914,140 @@ def test_federation_generate_key_creates_file(client, tmp_path, monkeypatch):
f"File contents must match returned key. "
f"File: {file_contents!r}, key: {returned_key!r}"
)
def test_get_auth_token_returns_401_when_not_authenticated(monkeypatch):
"""GET /api/auth/token returns 401 when request has no valid session cookie."""
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
with TestClient(app, base_url="http://192.168.1.1") as c:
# No cookie set — endpoint must return 401 with application/json accept
response = c.get("/api/auth/token", headers={"Accept": "application/json"})
assert response.status_code == 401
# ---------------------------------------------------------------------------
# Bug fix: delete_session must pass input="y\n" to subprocess.run
# ---------------------------------------------------------------------------
def test_delete_session_passes_stdin_y_to_subprocess(client, monkeypatch, tmp_path):
"""DELETE /api/sessions/{name} must pass input='y\\n' to subprocess.run.
When delete_session_template uses an interactive command (e.g. amplifier-dev
--destroy), the confirmation prompt must be auto-answered via stdin.
Without input='y\\n', subprocess.run hangs until 30s timeout and the
session is never actually deleted.
"""
from unittest.mock import MagicMock, patch
import muxplex.settings as settings_mod
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["my-session"])
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
captured_kwargs = []
def mock_run(cmd, **kwargs):
captured_kwargs.append(kwargs)
result = MagicMock()
result.returncode = 0
result.stderr = ""
return result
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
response = client.delete("/api/sessions/my-session")
assert response.status_code == 200
assert len(captured_kwargs) == 1, "subprocess.run must be called once"
kwargs = captured_kwargs[0]
assert "input" in kwargs, (
"subprocess.run must receive input= kwarg to auto-answer interactive prompts"
)
assert kwargs["input"] == "y\n", (
f"input must be 'y\\n' to confirm deletion, got: {kwargs['input']!r}"
)
# ---------------------------------------------------------------------------
# Bug fix: request-level INFO logging for session operations
# ---------------------------------------------------------------------------
def test_delete_session_logs_command_at_info(client, monkeypatch, tmp_path, caplog):
"""DELETE /api/sessions/{name} must log the command being run at INFO level."""
import logging
from unittest.mock import MagicMock, patch
import muxplex.settings as settings_mod
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["logged-session"])
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
def mock_run(cmd, **kwargs):
result = MagicMock()
result.returncode = 0
result.stderr = ""
return result
with caplog.at_level(logging.INFO, logger="muxplex.main"):
with patch("muxplex.main.subprocess.run", side_effect=mock_run):
client.delete("/api/sessions/logged-session")
log_messages = "\n".join(caplog.messages)
assert "logged-session" in log_messages, (
f"delete_session must log the session name at INFO level, got logs:\n{log_messages}"
)
def test_create_session_logs_command(client, monkeypatch, tmp_path, caplog):
"""POST /api/sessions must log the command being launched at INFO level."""
import logging
from unittest.mock import MagicMock, patch
import muxplex.settings as settings_mod
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "no-settings.json")
with caplog.at_level(logging.INFO, logger="muxplex.main"):
with patch("muxplex.main.subprocess.Popen") as mock_popen:
mock_popen.return_value = MagicMock()
client.post("/api/sessions", json={"name": "new-session"})
log_messages = "\n".join(caplog.messages)
assert "new-session" in log_messages, (
f"create_session must log session name at INFO level, got logs:\n{log_messages}"
)
def test_connect_session_logs_session_name(client, monkeypatch, caplog):
"""POST /api/sessions/{name}/connect must log the session name at INFO level."""
import logging
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["target-session"])
async def mock_kill_ttyd():
pass
async def mock_spawn_ttyd(name):
pass
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill_ttyd)
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn_ttyd)
with caplog.at_level(logging.INFO, logger="muxplex.main"):
client.post("/api/sessions/target-session/connect")
log_messages = "\n".join(caplog.messages)
assert "target-session" in log_messages, (
f"connect_session must log session name at INFO level, got logs:\n{log_messages}"
)
def test_cli_uvicorn_log_level_is_info():
"""cli.py serve() must pass log_level='info' to uvicorn.run so logs appear in journalctl."""
import inspect
from muxplex import cli
source = inspect.getsource(cli.serve)
assert 'log_level="info"' in source or "log_level='info'" in source, (
"serve() must call uvicorn.run(..., log_level='info') so application "
"logs appear in journalctl; currently set to 'warning' which suppresses them"
)
-1
View File
@@ -54,7 +54,6 @@ def test_css_zoom_transition():
def test_css_bell_count_and_toast():
css = read_css()
assert ".tile-bell-count" in css
assert ".connection-status--ok" in css
assert ".connection-status--warn" in css
assert ".connection-status--err" in css
+46
View File
@@ -1373,3 +1373,49 @@ def test_html_settings_remote_instance_key_input() -> None:
assert el is not None, (
"Missing #setting-remote-instances inside devices panel"
)
# ============================================================
# Clickable URLs — xterm-addon-web-links (task: clickable URLs)
# ============================================================
def read_html() -> str:
"""Read raw HTML content of index.html."""
return HTML_PATH.read_text()
def test_html_loads_web_links_addon() -> None:
"""index.html must load the xterm-addon-web-links CDN script."""
html = read_html()
assert "web-links" in html.lower() or "weblinks" in html.lower(), (
"Must load xterm-addon-web-links from CDN"
)
# ============================================================
# Search addon (xterm-addon-search)
# ============================================================
def test_html_loads_search_addon() -> None:
"""index.html must load the xterm-addon-search CDN script."""
html = read_html()
assert "search" in html.lower() and "addon" in html.lower() and "xterm" in html.lower(), (
"Must load xterm-addon-search from CDN"
)
def test_html_loads_image_addon() -> None:
"""index.html must load the xterm-addon-image CDN script."""
html = read_html()
assert "addon-image" in html.lower(), (
"Must load xterm-addon-image from CDN (expected 'addon-image' in script src)"
)
def test_html_has_search_bar() -> None:
"""index.html must contain the terminal search bar elements."""
html = read_html()
assert "terminal-search-bar" in html, "Must have #terminal-search-bar element"
assert "terminal-search-input" in html, "Must have #terminal-search-input element"
+74
View File
@@ -43,3 +43,77 @@ def test_readme_shows_restart_workflow():
assert "muxplex service restart" in README, (
"README must include 'muxplex service restart' in the example"
)
# ── Comprehensive documentation tests ─────────────────────────────────────
def test_readme_documents_all_settings_keys():
"""README must document every key from DEFAULT_SETTINGS."""
from muxplex.settings import DEFAULT_SETTINGS
for key in DEFAULT_SETTINGS:
assert f"`{key}`" in README, f"README must document setting key '{key}'"
def test_readme_has_keyboard_shortcuts_section():
"""README must have a keyboard shortcuts section."""
assert "Ctrl+Shift+C" in README, "README must document Ctrl+Shift+C"
assert "Ctrl+Shift+V" in README, "README must document Ctrl+Shift+V"
def test_readme_documents_clipboard_features():
"""README must mention clipboard and mouse select features."""
assert "clipboard" in README.lower(), "README must mention clipboard"
assert "mouse" in README.lower() or "select" in README.lower(), (
"README must mention mouse selection"
)
def test_readme_documents_all_cli_commands():
"""README must list all top-level CLI commands."""
commands = [
"muxplex doctor",
"muxplex upgrade",
"muxplex show-password",
"muxplex reset-secret",
"muxplex config",
]
for cmd in commands:
assert cmd in README, f"README must mention CLI command '{cmd}'"
def test_readme_has_platform_support_section():
"""README must document platform support."""
assert "systemd" in README, "README must mention systemd"
assert "launchd" in README, "README must mention launchd"
def test_readme_documents_auth_modes():
"""README must document both PAM and password auth."""
assert "PAM" in README, "README must mention PAM auth"
assert "password" in README.lower(), "README must mention password auth"
assert "localhost" in README.lower() or "127.0.0.1" in README, (
"README must mention localhost bypass"
)
def test_readme_documents_view_modes():
"""README must document Auto and Fit view modes."""
readme_lower = README.lower()
assert "view mode" in readme_lower or "view modes" in readme_lower, (
"README must mention view modes"
)
def test_readme_documents_ansi_color_previews():
"""README must document ANSI color previews in tiles."""
assert "ANSI" in README, "README must mention ANSI color previews"
def test_readme_documents_hover_preview():
"""README must document hover preview feature."""
readme_lower = README.lower()
assert "hover" in readme_lower and "preview" in readme_lower, (
"README must mention hover preview"
)
+29
View File
@@ -522,3 +522,32 @@ def test_prompt_host_missing_host_key_no_keyerror(monkeypatch):
# Must not raise KeyError
svc._prompt_host_if_localhost()
# ---------------------------------------------------------------------------
# Bug fix: Ctrl+C handling in logs functions (clean exit on KeyboardInterrupt)
# ---------------------------------------------------------------------------
def test_systemd_logs_handles_keyboard_interrupt(monkeypatch):
"""service logs must exit cleanly on Ctrl+C."""
import muxplex.service as svc
def mock_run(*args, **kwargs):
raise KeyboardInterrupt()
monkeypatch.setattr(subprocess, "run", mock_run)
# Should not raise
svc._systemd_logs()
def test_launchd_logs_handles_keyboard_interrupt(monkeypatch):
"""service logs must exit cleanly on Ctrl+C on macOS."""
import muxplex.service as svc
def mock_run(*args, **kwargs):
raise KeyboardInterrupt()
monkeypatch.setattr(subprocess, "run", mock_run)
# Should not raise
svc._launchd_logs()