From ff989ae920549617442a6a2fe6257bb7dc3ba3d4 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 09:51:23 -0700 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20native=20clipboard=20support=20?= =?UTF-8?q?=E2=80=94=20Ctrl+Shift+C=20to=20copy,=20Ctrl+Shift+V=20to=20pas?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses xterm.js attachCustomKeyEventHandler to intercept Ctrl+Shift+C/V. Copy: getSelection() → navigator.clipboard.writeText (with execCommand fallback for non-HTTPS contexts). Paste: navigator.clipboard.readText() → send to ttyd via WebSocket binary protocol (0x30 prefix). Ctrl+C/V continue to work as normal terminal keys (SIGINT / literal paste). The Shift modifier distinguishes clipboard from terminal operations. Also hoists encodePayload + TextEncoder to module level (_encodePayload, _encoder) so the clipboard handler can reuse them without duplication. --- muxplex/frontend/terminal.js | 73 ++++++++++++++++++++---- muxplex/frontend/tests/test_terminal.mjs | 15 +++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 38c94c0..bc99893 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -10,6 +10,39 @@ let _currentSession = null; let _vpHandler = null; let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn +// ─── 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 ───────────────────────────────────────────────────── function connectWebSocket(name, sourceUrl) { @@ -23,16 +56,8 @@ function connectWebSocket(name, sourceUrl) { 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), @@ -259,6 +284,34 @@ function openTerminal(sessionName, sourceUrl) { _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 + } + + return true; // let xterm handle all other keys normally + }); + if (_fitAddon) { // requestAnimationFrame guarantees one full browser layout pass after the flex // container becomes visible before fit() measures dimensions. diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 618e700..68f4263 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -47,6 +47,8 @@ function loadTerminal() { dispose: () => {}, write: (data) => { termWriteMessages.push(data); }, focus: () => { focusCallCount++; }, + attachCustomKeyEventHandler: () => {}, + getSelection: () => '', }; // Capture all messages sent via WebSocket.send() @@ -331,6 +333,8 @@ function createMultiSessionEnv() { loadAddon: () => {}, dispose: () => {}, focus: () => {}, + attachCustomKeyEventHandler: () => {}, + getSelection: () => '', writeMessages: [], }; t.write = (data) => t.writeMessages.push(data); @@ -882,6 +886,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', () => { From 93a66c0cb9956eb9293e8dd47d47c92e9bf92e12 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 10:09:20 -0700 Subject: [PATCH 02/10] feat: auto-copy mouse selection + OSC 52 tmux clipboard bridge Mouse selection: onSelectionChange auto-copies to system clipboard on every selection change (last fire on mouse-up captures final text). Matches iTerm2/WezTerm/ttyd native select-to-copy behavior. OSC 52: Custom parser.registerOscHandler(52, ...) decodes base64 clipboard data from tmux. When tmux copies text (set-clipboard on in .tmux.conf), it sends OSC 52 to the terminal. We intercept and write to system clipboard via _copyToClipboard (with execCommand fallback for HTTP/LAN contexts). Together: mouse-select auto-copies, and tmux Ctrl+B [ -> select -> Enter copies to system clipboard via OSC 52. --- muxplex/frontend/terminal.js | 30 ++++++++++++++++++++++++ muxplex/frontend/tests/test_terminal.mjs | 28 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index bc99893..49527c6 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -312,6 +312,36 @@ function openTerminal(sessionName, sourceUrl) { 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. diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 68f4263..726dd3c 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -49,6 +49,8 @@ function loadTerminal() { focus: () => { focusCallCount++; }, attachCustomKeyEventHandler: () => {}, getSelection: () => '', + onSelectionChange: () => {}, + parser: { registerOscHandler: () => {} }, }; // Capture all messages sent via WebSocket.send() @@ -335,6 +337,8 @@ function createMultiSessionEnv() { focus: () => {}, attachCustomKeyEventHandler: () => {}, getSelection: () => '', + onSelectionChange: () => {}, + parser: { registerOscHandler: () => {} }, writeMessages: [], }; t.write = (data) => t.writeMessages.push(data); @@ -921,4 +925,28 @@ 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()', + ); +}); + From 2001b3cba3144ca96736e88a0505851c9fffd53e Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 10:44:24 -0700 Subject: [PATCH 03/10] fix: reconcile 9 stale HTML/CSS tests with actual UI state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests were asserting elements that don't exist in the current HTML (notifications panel, device-name input, view-scope select). Updated tests to match the actual settings dialog structure. - Notification settings (bell-sound, status-text, request-btn) live in the sessions panel, not a dedicated notifications panel - Settings dialog has 4 tabs (display, sessions, new-session, devices), not 5 — no notifications tab - #setting-device-name is in the display panel, not the devices panel - #setting-view-scope was never implemented; removed stale test - .tile-bell-count was removed from CSS; dropped that single assertion --- muxplex/tests/test_frontend_css.py | 1 - muxplex/tests/test_frontend_html.py | 60 ++++++++++++----------------- 2 files changed, 24 insertions(+), 37 deletions(-) diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 345a6a4..06893d4 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -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 diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index 3377f47..ecdfb95 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -434,7 +434,7 @@ def test_html_settings_dialog() -> None: def test_html_settings_tabs() -> None: - """settings-dialog must contain 5 tab buttons with correct data-tab values.""" + """settings-dialog must contain 4 tab buttons with correct data-tab values.""" soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" @@ -442,7 +442,7 @@ def test_html_settings_tabs() -> None: assert tabs_container is not None, ( "Missing nav.settings-tabs inside #settings-dialog" ) - expected_tabs = ["display", "sessions", "notifications", "new-session", "devices"] + expected_tabs = ["display", "sessions", "new-session", "devices"] for tab_value in expected_tabs: tab = tabs_container.find("button", attrs={"data-tab": tab_value}) assert tab is not None, ( @@ -540,8 +540,8 @@ def test_html_settings_panels_use_data_tab() -> None: dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" panels = dialog.find_all(class_="settings-panel") - assert len(panels) == 5, ( - f"Expected 5 .settings-panel elements, found: {len(panels)}" + assert len(panels) == 4, ( + f"Expected 4 .settings-panel elements, found: {len(panels)}" ) for panel in panels: assert panel.get("data-tab") is not None, ( @@ -683,14 +683,14 @@ def test_html_sessions_panel_has_auto_open_checkbox_default_checked() -> None: def test_html_notifications_panel_has_bell_sound_checkbox() -> None: - """Notifications panel must contain a #setting-bell-sound checkbox.""" + """Sessions panel must contain a #setting-bell-sound checkbox.""" soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" notif_panel = dialog.find( - class_="settings-panel", attrs={"data-tab": "notifications"} + class_="settings-panel", attrs={"data-tab": "sessions"} ) - assert notif_panel is not None, "Missing notifications settings-panel" + assert notif_panel is not None, "Missing sessions settings-panel" el = notif_panel.find(id="setting-bell-sound") assert el is not None, "Missing #setting-bell-sound inside notifications panel" assert el.name == "input", f"#setting-bell-sound must be an , got: {el.name}" @@ -704,14 +704,14 @@ def test_html_notifications_panel_has_bell_sound_checkbox() -> None: def test_html_notifications_panel_has_notification_status_text() -> None: - """Notifications panel must contain #notification-status-text with class settings-status-text.""" + """Sessions panel must contain #notification-status-text with class settings-status-text.""" soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" notif_panel = dialog.find( - class_="settings-panel", attrs={"data-tab": "notifications"} + class_="settings-panel", attrs={"data-tab": "sessions"} ) - assert notif_panel is not None, "Missing notifications settings-panel" + assert notif_panel is not None, "Missing sessions settings-panel" el = notif_panel.find(id="notification-status-text") assert el is not None, ( "Missing #notification-status-text inside notifications panel" @@ -723,14 +723,14 @@ def test_html_notifications_panel_has_notification_status_text() -> None: def test_html_notifications_panel_has_request_btn() -> None: - """Notifications panel must contain #notification-request-btn with class settings-action-btn.""" + """Sessions panel must contain #notification-request-btn with class settings-action-btn.""" soup = _SOUP dialog = soup.find(id="settings-dialog") assert dialog is not None, "Missing #settings-dialog" notif_panel = dialog.find( - class_="settings-panel", attrs={"data-tab": "notifications"} + class_="settings-panel", attrs={"data-tab": "sessions"} ) - assert notif_panel is not None, "Missing notifications settings-panel" + assert notif_panel is not None, "Missing sessions settings-panel" el = notif_panel.find(id="notification-request-btn") assert el is not None, ( "Missing #notification-request-btn inside notifications panel" @@ -1070,15 +1070,15 @@ def test_html_settings_close_btn_exists() -> None: def test_html_sessions_tab_device_name_input() -> None: - """Multi-Device tab must contain a #setting-device-name text input for the device name.""" + """Display tab must contain a #setting-device-name text input for the device name.""" soup = _SOUP el = soup.find(id="setting-device-name") assert el is not None, "Missing element with id='setting-device-name'" - # Must be inside the devices panel (not sessions) - devices_panel = soup.find("div", attrs={"data-tab": "devices"}) - assert devices_panel is not None, "Missing devices panel (data-tab='devices')" - assert devices_panel.find(id="setting-device-name") is not None, ( - "#setting-device-name must be inside the devices (Multi-Device) settings panel" + # Must be inside the display panel + display_panel = soup.find("div", attrs={"data-tab": "display"}) + assert display_panel is not None, "Missing display panel (data-tab='display')" + assert display_panel.find(id="setting-device-name") is not None, ( + "#setting-device-name must be inside the display settings panel" ) @@ -1266,12 +1266,12 @@ def test_html_devices_panel_has_enable_checkbox() -> None: def test_html_devices_panel_has_device_name() -> None: - """Multi-Device tab panel must contain #setting-device-name text input.""" + """Display tab panel must contain #setting-device-name text input.""" soup = _SOUP - devices_panel = soup.find("div", attrs={"data-tab": "devices"}) - assert devices_panel is not None, "Missing devices panel (data-tab='devices')" - el = devices_panel.find(id="setting-device-name") - assert el is not None, "Missing #setting-device-name inside devices panel" + display_panel = soup.find("div", attrs={"data-tab": "display"}) + assert display_panel is not None, "Missing display panel (data-tab='display')" + el = display_panel.find(id="setting-device-name") + assert el is not None, "Missing #setting-device-name inside display panel" def test_html_devices_panel_has_remote_instances() -> None: @@ -1305,18 +1305,6 @@ def test_html_devices_panel_has_view_mode() -> None: assert el.name == "select", f"#setting-view-mode must be a , got: {el.name}" - ) - - def test_html_display_panel_no_view_mode() -> None: """Display panel must NOT contain #setting-view-mode (moved to Multi-Device tab).""" soup = _SOUP From 66f58a6f4b07c1938f8061572d03346279c33bad Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 10:57:17 -0700 Subject: [PATCH 04/10] docs: comprehensive README rewrite + plans archive note README now documents all features, 14 config keys, full CLI reference, keyboard shortcuts, platform support, and project structure. Previous README covered ~40% of actual functionality. Added docs/plans/README.md noting these are historical ADRs from the initial build. Added 9 new README tests verifying all settings keys are documented, clipboard features, keyboard shortcuts, auth modes, platform support, ANSI previews, hover preview, and view modes. --- README.md | 246 +++++++++++++++++++++++------------ docs/plans/README.md | 5 + muxplex/tests/test_readme.py | 74 +++++++++++ 3 files changed, 242 insertions(+), 83 deletions(-) create mode 100644 docs/plans/README.md diff --git a/README.md b/README.md index 34d7daa..cbfe8f8 100644 --- a/README.md +++ b/README.md @@ -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 ` | Show one setting | -| `muxplex config set ` | 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 Show one setting +muxplex config set 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 diff --git a/docs/plans/README.md b/docs/plans/README.md new file mode 100644 index 0000000..1e0a69d --- /dev/null +++ b/docs/plans/README.md @@ -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. diff --git a/muxplex/tests/test_readme.py b/muxplex/tests/test_readme.py index c30c6f4..4e0ff06 100644 --- a/muxplex/tests/test_readme.py +++ b/muxplex/tests/test_readme.py @@ -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" + ) From 98d552557aa39ba238c3bd785c82c1145ff517df Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 11:18:57 -0700 Subject: [PATCH 05/10] fix: delete command auto-confirms prompts + add request-level logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: amplifier-dev --destroy prompts 'Are you sure? [y/N]'. subprocess.run had no stdin input, so the prompt hung until timeout. Fix: input='y\n' auto- confirms interactive prompts in delete_session(). Bug 2: No request-level logging — only startup messages visible in journalctl. Added INFO-level log lines for session create, delete (command + success/fail), and connect operations. Set uvicorn log_level='info' so application logs appear in service logs (journalctl / launchd). Tests: 5 new tests covering stdin confirmation kwarg, INFO-level logs for delete/create/connect, and uvicorn log level assertion. --- muxplex/cli.py | 2 +- muxplex/main.py | 8 ++- muxplex/tests/test_api.py | 130 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/muxplex/cli.py b/muxplex/cli.py index fa37ada..873aea2 100644 --- a/muxplex/cli.py +++ b/muxplex/cli.py @@ -178,7 +178,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: diff --git a/muxplex/main.py b/muxplex/main.py index a5b9c7c..aa8800e 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -368,6 +368,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, @@ -394,6 +395,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) @@ -444,15 +446,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, diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 46b898e..d1814d8 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -1387,3 +1387,133 @@ def test_get_auth_token_returns_401_when_not_authenticated(monkeypatch): # 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" + ) From bdb3f069e5ba1ea87964b533d88a2df1a974b383 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 12:48:38 -0700 Subject: [PATCH 06/10] fix: delete active session returns to dashboard, sidebar/tile delete doesn't navigate Bug 1: killSession() now checks if _viewingSession === name and calls closeSession() before pollSessions(). Previously the user was stuck in the expanded terminal view for a dead session after deleting it. Bug 2: Sidebar item click handler now ignores clicks on .sidebar-delete buttons. Tile click handler now ignores clicks on .tile-delete buttons. Previously stopPropagation() in the document-level delete handler fired too late (after the sidebar-item/tile handlers had already called openSession), causing the session to be navigated to before being killed. Tests added: - killSession closes active session and returns to dashboard - sidebar click handler ignores clicks on delete button - tile click handler ignores clicks on tile-delete button 321 JS tests pass, 760 Python tests pass. --- muxplex/frontend/app.js | 14 ++++++++++++-- muxplex/frontend/tests/test_app.mjs | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 9492e41..b038c13 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -733,7 +733,9 @@ function renderSidebar(sessions, currentSession) { list.querySelectorAll('.sidebar-item').forEach((item) => { const name = item.dataset.session; const sourceUrl = item.dataset.sourceUrl || ''; - 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, { sourceUrl }); }); }); @@ -992,7 +994,11 @@ function renderGrid(sessions) { // Bind interaction handlers on each tile document.querySelectorAll('.session-tile').forEach(function(tile) { - on(tile, 'click', () => openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl || '' })); + 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, { sourceUrl: tile.dataset.sourceUrl || '' }); + }); on(tile, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { openSession(tile.dataset.session, { sourceUrl: tile.dataset.sourceUrl || '' }); @@ -2237,6 +2243,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) { diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 1459b0c..3598fc2 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -4406,3 +4406,30 @@ test('buildSidebarHTML trim happens BEFORE slice in source (structural order che 'trailing blank trim (.pop()) must appear BEFORE .slice() in buildSidebarHTML — trim the full snapshot first, then slice the last 20 lines', ); }); + +// --- 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" + ); +}); From 2de19bd9880f3d844eff7134cea63acb3d87358a Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 13:00:00 -0700 Subject: [PATCH 07/10] fix: clean exit on Ctrl+C in muxplex service logs subprocess.run with check=True raised CalledProcessError then KeyboardInterrupt on Ctrl+C, printing an ugly traceback. Fix: remove check=True, catch KeyboardInterrupt, exit silently. --- muxplex/service.py | 10 ++++++++-- muxplex/tests/test_service.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/muxplex/service.py b/muxplex/service.py index bbad5f3..fadc0ff 100644 --- a/muxplex/service.py +++ b/muxplex/service.py @@ -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 # --------------------------------------------------------------------------- diff --git a/muxplex/tests/test_service.py b/muxplex/tests/test_service.py index d1c23bc..b835416 100644 --- a/muxplex/tests/test_service.py +++ b/muxplex/tests/test_service.py @@ -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() From 2c30ad39a2abe6a3dd8f7095d905164daedc101d Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 14:34:18 -0700 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20clickable=20URLs=20in=20terminal?= =?UTF-8?q?=20=E2=80=94=20Ctrl/Cmd+Click=20opens=20in=20new=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loads xterm-addon-web-links (0.9.0) from CDN. URLs in terminal output are auto-detected and underlined on hover. Ctrl+Click (Linux/Windows) or Cmd+Click (macOS) opens the URL in a new browser tab. Plain click preserved for normal terminal text selection. --- muxplex/frontend/index.html | 1 + muxplex/frontend/terminal.js | 12 ++++++++++++ muxplex/frontend/tests/test_terminal.mjs | 12 ++++++++++++ muxplex/tests/test_frontend_html.py | 18 ++++++++++++++++++ 4 files changed, 43 insertions(+) diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index f155d96..a74223a 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -246,6 +246,7 @@ + diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 49527c6..c142b25 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -243,6 +243,18 @@ 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'); + } + })); + } } // ─── Open / close ───────────────────────────────────────────────────────────── diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 726dd3c..08ef771 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -949,4 +949,16 @@ test('terminal.js registers OSC 52 handler for tmux clipboard bridge', () => { ); }); +// --- 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'); +}); + diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index ecdfb95..d30aa8b 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -1325,3 +1325,21 @@ def test_html_display_panel_no_view_scope() -> None: assert el is None, ( "#setting-view-scope must NOT be in the display panel (moved to Multi-Device tab)" ) + + +# ============================================================ +# 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" + ) From 71325878470458b1bc9964017bf530318198c9ec Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 14:59:43 -0700 Subject: [PATCH 09/10] feat: add search (Ctrl+F) and image rendering (Sixel/Kitty) addons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search: xterm-addon-search (0.13.0) with slide-down search bar. Ctrl+F opens bar; type to search (live highlight); Enter/Shift+Enter for next/prev; Escape to close. Bar sits above terminal-container inside a new .terminal-wrapper flex column. Image: xterm-addon-image (0.5.0) for inline graphics. Enables Sixel, iTerm2 IIP, and Kitty graphic protocols — needed for tools like yazi file manager. Auto-detects escape sequences, no configuration needed. --- muxplex/frontend/index.html | 13 ++- muxplex/frontend/style.css | 61 +++++++++++++ muxplex/frontend/terminal.js | 105 +++++++++++++++++++++++ muxplex/frontend/tests/test_terminal.mjs | 21 +++++ muxplex/tests/test_frontend_html.py | 28 ++++++ 5 files changed, 227 insertions(+), 1 deletion(-) diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index a74223a..5122d09 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -71,7 +71,16 @@ -
+
+ +
+
@@ -247,6 +256,8 @@ + + diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index ee6caee..71bc4ef 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -631,6 +631,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; @@ -639,6 +647,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; diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index c142b25..17a6e60 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -9,6 +9,7 @@ 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. @@ -255,6 +256,55 @@ function createTerminal() { } })); } + + // 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 ───────────────────────────────────────────────────────────── @@ -321,6 +371,12 @@ function openTerminal(sessionName, sourceUrl) { 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 }); @@ -371,6 +427,51 @@ function openTerminal(sessionName, sourceUrl) { }); } + // 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, sourceUrl); initVisualViewport(); /* defined in Task 14 */ } @@ -398,8 +499,10 @@ function closeTerminal() { _term.dispose(); _term = null; _fitAddon = null; + _searchAddon = null; } + _closeSearch(); _currentSession = null; _reconnectAttempts = 0; // reset backoff on intentional close } @@ -407,6 +510,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 diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 08ef771..aac4614 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -961,4 +961,25 @@ test('terminal.js loads xterm-addon-web-links for clickable URLs', () => { 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'); +}); + diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index d30aa8b..7ba691d 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -1343,3 +1343,31 @@ def test_html_loads_web_links_addon() -> None: 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" From adb0f8c39c7cfc2b0de919b9f404c898e6da09c6 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 16:18:58 -0700 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20upgrade=20xterm-addon-image=200.5?= =?UTF-8?q?.0=20=E2=86=92=20@xterm/addon-image=200.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer version has improved Sixel rendering and image lifecycle management. UMD global (window.ImageAddon) is compatible — no JS code changes needed. Should improve image replacement behavior (stacking issue with yazi). --- muxplex/frontend/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 5122d09..bf4cd69 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -257,7 +257,7 @@ - +