merge: integrate upstream (clipboard, URL click, search addon, delete fixes) with federation proxy rewrite
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# muxplex
|
# 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.**
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -8,12 +8,51 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Live session grid** — thumbnail snapshots of every running tmux session, auto-refreshed
|
### Dashboard
|
||||||
- **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
|
- **Live session grid** — preview tiles with ANSI-colored terminal snapshots, auto-refreshed
|
||||||
- **Bell & activity notifications** — visual alerts when any session rings a bell or has new output
|
- **Two view modes** — Auto (scrollable grid) and Fit (all sessions fill the viewport)
|
||||||
- **Mobile-friendly responsive layout** — works on phones and tablets; PWA-capable for home-screen install
|
- **Hover preview** — full-size overlay of session content on tile hover
|
||||||
- **Works over Tailscale / private network** — serve to any device on your network without exposing to the internet
|
- **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`
|
- Ubuntu/WSL: `sudo apt install ttyd` or `sudo snap install ttyd`
|
||||||
- Other: https://github.com/tsl0922/ttyd#installation
|
- 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 Permanently
|
||||||
|
|
||||||
Install muxplex as a persistent CLI tool using `uv tool`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install git+https://github.com/bkrabach/muxplex
|
uv tool install git+https://github.com/bkrabach/muxplex
|
||||||
|
muxplex doctor # verify dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run it any time with:
|
Then run it any time with:
|
||||||
@@ -66,49 +104,43 @@ muxplex
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
muxplex service install
|
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.
|
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:
|
To stop and remove:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
muxplex service uninstall
|
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
|
|
||||||
```
|
```
|
||||||
|
muxplex Start server (default)
|
||||||
All serve options read from `~/.config/muxplex/settings.json` by default. CLI flags override for that run only.
|
muxplex serve [flags] Start with CLI flag overrides
|
||||||
|
muxplex service install Install + enable + start as OS service
|
||||||
| Option | settings.json key | Default | Description |
|
muxplex service uninstall Stop + disable + remove
|
||||||
|---|---|---|---|
|
muxplex service start|stop|restart Manage running service
|
||||||
| `--host HOST` | `host` | `127.0.0.1` | Interface to bind (`0.0.0.0` for network access) |
|
muxplex service status Show service status
|
||||||
| `--port PORT` | `port` | `8088` | Port to listen on |
|
muxplex service logs Tail service logs
|
||||||
| `--auth MODE` | `auth` | `pam` | Auth method: `pam` or `password` |
|
muxplex config Show all settings
|
||||||
| `--session-ttl SEC` | `session_ttl` | `604800` | Session TTL in seconds (7 days; 0 = browser session) |
|
muxplex config get <key> Show one setting
|
||||||
|
muxplex config set <key> <value> Set a setting
|
||||||
### Other commands
|
muxplex config reset [key] Reset one or all to defaults
|
||||||
|
muxplex upgrade [--force] Smart update with version check
|
||||||
| Command | Description |
|
muxplex doctor Check dependencies + config
|
||||||
|---|---|
|
muxplex show-password Show current auth password
|
||||||
| `muxplex doctor` | Check dependencies and system status |
|
muxplex reset-secret Regenerate signing secret
|
||||||
| `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 |
|
|
||||||
|
|
||||||
### Service management
|
### Service management
|
||||||
|
|
||||||
@@ -122,11 +154,10 @@ muxplex service status # Show running/stopped + PID
|
|||||||
muxplex service logs # Tail service logs
|
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
|
```bash
|
||||||
# Edit settings to bind to all interfaces
|
muxplex config set host 0.0.0.0
|
||||||
# (or use the Settings UI in the browser)
|
|
||||||
muxplex service restart
|
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
|
## Development
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
@@ -169,7 +283,7 @@ python -m muxplex
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Python tests (pytest)
|
# Python tests (pytest)
|
||||||
pytest
|
python -m pytest muxplex/tests/ --ignore=muxplex/tests/test_integration.py
|
||||||
|
|
||||||
# JavaScript tests (node:test)
|
# JavaScript tests (node:test)
|
||||||
node --test muxplex/frontend/tests/test_terminal.mjs
|
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
|
## 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.
|
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
|
```bash
|
||||||
python3 scripts/render-brand-assets.py
|
python3 scripts/render-brand-assets.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -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
@@ -191,7 +191,7 @@ def serve(
|
|||||||
from muxplex.main import app # noqa: PLC0415
|
from muxplex.main import app # noqa: PLC0415
|
||||||
|
|
||||||
print(f" muxplex → http://{host}:{port}")
|
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:
|
def doctor() -> None:
|
||||||
|
|||||||
+12
-2
@@ -581,7 +581,9 @@ function renderSidebar(sessions, currentSession) {
|
|||||||
list.querySelectorAll('.sidebar-item').forEach((item) => {
|
list.querySelectorAll('.sidebar-item').forEach((item) => {
|
||||||
const name = item.dataset.session;
|
const name = item.dataset.session;
|
||||||
const remoteId = item.dataset.remoteId || '';
|
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 });
|
if (name !== currentSession) openSession(name, { remoteId });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -833,7 +835,11 @@ function renderGrid(sessions) {
|
|||||||
|
|
||||||
// Bind interaction handlers on each tile
|
// Bind interaction handlers on each tile
|
||||||
document.querySelectorAll('.session-tile').forEach(function(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) => {
|
on(tile, 'keydown', (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
|
openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' });
|
||||||
@@ -2031,6 +2037,10 @@ function killSession(name) {
|
|||||||
api('DELETE', '/api/sessions/' + name)
|
api('DELETE', '/api/sessions/' + name)
|
||||||
.then(function() {
|
.then(function() {
|
||||||
showToast('Session \'' + name + '\' killed');
|
showToast('Session \'' + name + '\' killed');
|
||||||
|
// If we deleted the session we're currently viewing, return to dashboard
|
||||||
|
if (_viewingSession === name) {
|
||||||
|
closeSession();
|
||||||
|
}
|
||||||
pollSessions();
|
pollSessions();
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
|
|||||||
@@ -50,8 +50,17 @@
|
|||||||
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
|
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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)">▲</button>
|
||||||
|
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">▼</button>
|
||||||
|
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">×</button>
|
||||||
|
</div>
|
||||||
<div id="terminal-container" class="terminal-container"></div>
|
<div id="terminal-container" class="terminal-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -233,6 +242,9 @@
|
|||||||
<!-- ── Scripts ──────────────────────────────────────────────────────────── -->
|
<!-- ── Scripts ──────────────────────────────────────────────────────────── -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
<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-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="/app.js" defer></script>
|
||||||
<script src="/terminal.js" defer></script>
|
<script src="/terminal.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -630,6 +630,14 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -638,6 +646,59 @@ body {
|
|||||||
padding: 0 4px; /* keep text off the side edges */
|
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.js injects overflow-y: scroll on .xterm-viewport — override it */
|
||||||
.xterm .xterm-viewport {
|
.xterm .xterm-viewport {
|
||||||
overflow-y: hidden !important;
|
overflow-y: hidden !important;
|
||||||
|
|||||||
+210
-10
@@ -9,6 +9,40 @@ let _reconnectTimer = null;
|
|||||||
let _currentSession = null;
|
let _currentSession = null;
|
||||||
let _vpHandler = null;
|
let _vpHandler = null;
|
||||||
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
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 ─────────────────────────────────────────────────────
|
// ─── Forward declarations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -26,16 +60,8 @@ function connectWebSocket(name, remoteId) {
|
|||||||
url = proto + '//' + location.host + '/terminal/ws';
|
url = proto + '//' + location.host + '/terminal/ws';
|
||||||
}
|
}
|
||||||
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
||||||
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
// Use module-level _encodePayload (hoisted above connectWebSocket)
|
||||||
|
var encodePayload = _encodePayload;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register terminal event handlers once on this _term instance.
|
// Register terminal event handlers once on this _term instance.
|
||||||
// These handlers read the module-level _ws at call time (not a captured reference),
|
// 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();
|
_fitAddon = new window.FitAddon.FitAddon();
|
||||||
_term.loadAddon(_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 ─────────────────────────────────────────────────────────────
|
// ─── Open / close ─────────────────────────────────────────────────────────────
|
||||||
@@ -262,6 +349,70 @@ function openTerminal(sessionName, remoteId) {
|
|||||||
|
|
||||||
_term.open(container);
|
_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) {
|
if (_fitAddon) {
|
||||||
// requestAnimationFrame guarantees one full browser layout pass after the flex
|
// requestAnimationFrame guarantees one full browser layout pass after the flex
|
||||||
// container becomes visible before fit() measures dimensions.
|
// 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);
|
connectWebSocket(sessionName, remoteId);
|
||||||
initVisualViewport(); /* defined in Task 14 */
|
initVisualViewport(); /* defined in Task 14 */
|
||||||
}
|
}
|
||||||
@@ -306,8 +502,10 @@ function closeTerminal() {
|
|||||||
_term.dispose();
|
_term.dispose();
|
||||||
_term = null;
|
_term = null;
|
||||||
_fitAddon = null;
|
_fitAddon = null;
|
||||||
|
_searchAddon = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_closeSearch();
|
||||||
_currentSession = null;
|
_currentSession = null;
|
||||||
_reconnectAttempts = 0; // reset backoff on intentional close
|
_reconnectAttempts = 0; // reset backoff on intentional close
|
||||||
}
|
}
|
||||||
@@ -315,6 +513,8 @@ function closeTerminal() {
|
|||||||
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
||||||
window._openTerminal = openTerminal;
|
window._openTerminal = openTerminal;
|
||||||
window._closeTerminal = closeTerminal;
|
window._closeTerminal = closeTerminal;
|
||||||
|
window._openSearch = _openSearch;
|
||||||
|
window._closeSearch = _closeSearch;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// setTerminalFontSize — live font-size update without reconnecting
|
// setTerminalFontSize — live font-size update without reconnecting
|
||||||
|
|||||||
@@ -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()',
|
'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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ function loadTerminal() {
|
|||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
write: (data) => { termWriteMessages.push(data); },
|
write: (data) => { termWriteMessages.push(data); },
|
||||||
focus: () => { focusCallCount++; },
|
focus: () => { focusCallCount++; },
|
||||||
|
attachCustomKeyEventHandler: () => {},
|
||||||
|
getSelection: () => '',
|
||||||
|
onSelectionChange: () => {},
|
||||||
|
parser: { registerOscHandler: () => {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture all messages sent via WebSocket.send()
|
// Capture all messages sent via WebSocket.send()
|
||||||
@@ -331,6 +335,10 @@ function createMultiSessionEnv() {
|
|||||||
loadAddon: () => {},
|
loadAddon: () => {},
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
focus: () => {},
|
focus: () => {},
|
||||||
|
attachCustomKeyEventHandler: () => {},
|
||||||
|
getSelection: () => '',
|
||||||
|
onSelectionChange: () => {},
|
||||||
|
parser: { registerOscHandler: () => {} },
|
||||||
writeMessages: [],
|
writeMessages: [],
|
||||||
};
|
};
|
||||||
t.write = (data) => t.writeMessages.push(data);
|
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 ---
|
// --- Issue 4: setTerminalFontSize ---
|
||||||
|
|
||||||
test('terminal.js exposes window._setTerminalFontSize function', () => {
|
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
@@ -366,6 +366,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
|
|||||||
name = payload.name
|
name = payload.name
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
command = settings["new_session_template"].replace("{name}", name)
|
command = settings["new_session_template"].replace("{name}", name)
|
||||||
|
_log.info("Creating session '%s' with command: %s", name, command)
|
||||||
try:
|
try:
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
@@ -392,6 +393,7 @@ async def connect_session(name: str) -> dict:
|
|||||||
if known and name not in known:
|
if known and name not in known:
|
||||||
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
|
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
|
||||||
|
|
||||||
|
_log.info("Connecting to session '%s'", name)
|
||||||
await kill_ttyd()
|
await kill_ttyd()
|
||||||
await spawn_ttyd(name)
|
await spawn_ttyd(name)
|
||||||
|
|
||||||
@@ -442,15 +444,19 @@ async def delete_session(name: str) -> dict:
|
|||||||
"delete_session_template", "tmux kill-session -t {name}"
|
"delete_session_template", "tmux kill-session -t {name}"
|
||||||
).replace("{name}", name)
|
).replace("{name}", name)
|
||||||
|
|
||||||
|
_log.info("Deleting session '%s' with command: %s", name, command)
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
command,
|
command,
|
||||||
shell=True,
|
shell=True,
|
||||||
|
input="y\n", # auto-confirm interactive prompts (e.g. amplifier-dev --destroy)
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode == 0:
|
||||||
|
_log.info("Session '%s' deleted successfully", name)
|
||||||
|
else:
|
||||||
_log.warning(
|
_log.warning(
|
||||||
"Delete command failed (rc=%d): %s",
|
"Delete command failed (rc=%d): %s",
|
||||||
result.returncode,
|
result.returncode,
|
||||||
|
|||||||
+8
-2
@@ -152,7 +152,10 @@ def _systemd_status() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _systemd_logs() -> 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:
|
def _launchd_logs() -> None:
|
||||||
subprocess.run(["tail", "-f", "/tmp/muxplex.log"], check=True)
|
try:
|
||||||
|
subprocess.run(["tail", "-f", "/tmp/muxplex.log"])
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1914,3 +1914,140 @@ def test_federation_generate_key_creates_file(client, tmp_path, monkeypatch):
|
|||||||
f"File contents must match returned key. "
|
f"File contents must match returned key. "
|
||||||
f"File: {file_contents!r}, key: {returned_key!r}"
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ def test_css_zoom_transition():
|
|||||||
|
|
||||||
def test_css_bell_count_and_toast():
|
def test_css_bell_count_and_toast():
|
||||||
css = read_css()
|
css = read_css()
|
||||||
assert ".tile-bell-count" in css
|
|
||||||
assert ".connection-status--ok" in css
|
assert ".connection-status--ok" in css
|
||||||
assert ".connection-status--warn" in css
|
assert ".connection-status--warn" in css
|
||||||
assert ".connection-status--err" in css
|
assert ".connection-status--err" in css
|
||||||
|
|||||||
@@ -1373,3 +1373,49 @@ def test_html_settings_remote_instance_key_input() -> None:
|
|||||||
assert el is not None, (
|
assert el is not None, (
|
||||||
"Missing #setting-remote-instances inside devices panel"
|
"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"
|
||||||
|
|||||||
@@ -43,3 +43,77 @@ def test_readme_shows_restart_workflow():
|
|||||||
assert "muxplex service restart" in README, (
|
assert "muxplex service restart" in README, (
|
||||||
"README must include 'muxplex service restart' in the example"
|
"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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -522,3 +522,32 @@ def test_prompt_host_missing_host_key_no_keyerror(monkeypatch):
|
|||||||
|
|
||||||
# Must not raise KeyError
|
# Must not raise KeyError
|
||||||
svc._prompt_host_if_localhost()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user