diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 2945caa..fa4adb9 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -2896,7 +2896,7 @@ async function openSession(name, opts = {}) { initSidebar(); renderSidebar(_currentSessions, name, _viewingRemoteId); resolve(); - }, opts.skipAnimation ? 0 : (tile ? 260 : 0)); + }, 0); // If setTimeout is stubbed (e.g. in test env), resolve immediately so we don't hang if (timerId == null) resolve(); }); @@ -3493,9 +3493,14 @@ function handleGlobalKeydown(e) { if (e.key === 'Escape') { closeSettings(); } return; } - // Determine if focus is inside a text input + // Determine if focus is inside a text input. + // Also treat contenteditable elements (ghostty-web's terminal container uses + // contenteditable="true") and anything inside #terminal-container as "in input" + // so that keyboard shortcuts don't steal keystrokes from the terminal. const tag = document.activeElement && document.activeElement.tagName; - const inInput = (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'); + const inInput = (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' + || !!(document.activeElement && document.activeElement.isContentEditable) + || !!(document.activeElement && document.activeElement.closest && document.activeElement.closest('#terminal-container'))); // Comma key (not in inputs, no ctrl/meta) opens settings if (e.key === ',' && !e.ctrlKey && !e.metaKey && !inInput) { openSettings(); diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index a3dcaed..87f70c3 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -501,7 +501,6 @@ body { flex-direction: column; overflow: hidden; flex-shrink: 0; - transition: width 0.25s ease, min-width 0.25s ease; } .session-sidebar.sidebar--collapsed { @@ -735,7 +734,6 @@ body { min-width: 0; overflow: hidden; background: #000; - padding: 0 4px; /* keep text off the side edges */ } /* Terminal search bar */ @@ -818,12 +816,6 @@ body { position: fixed; z-index: 50; border-radius: 4px; - transition: - top var(--t-zoom), - left var(--t-zoom), - width var(--t-zoom), - height var(--t-zoom), - border-radius var(--t-zoom); } .session-tile--expanded { diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 30bb833..1320664 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -16,6 +16,7 @@ let _ctrlActive = false; let _altActive = false; let _muxtermPort = null; let _muxtermToken = null; +let _container = null; // the element terminal was opened into // WASM initialization for ghostty-web let _ghosttyReady = (typeof GhosttyWeb !== 'undefined' && GhosttyWeb.init) @@ -42,6 +43,21 @@ function _copyToClipboard(text) { } } +// ——— Focus helper —————————————————————————————————————————————————————————————————— +/** + * Focus the terminal container for keyboard input. + * ghostty-web's InputHandler listens for 'keydown' on the container element. + * Focusing the hidden textarea instead causes intermittent keystroke loss: + * the textarea's IME handling sets isComposing=true / keyCode=229 on some + * events, which ghostty-web's handleKeyDown() ignores entirely. + * Focus the container directly — it has tabindex=0 and contenteditable=true. + */ +function _focusTermInput() { + if (_container) { + _container.focus(); + } +} + // ——— WebSocket connection ———————————————————————————————————————————— /** * Fetch a terminal token from the API, then open a WebSocket to muxterm. @@ -92,7 +108,13 @@ function _openMuxtermSocket() { if (_currentSession) { ws.send(JSON.stringify({ attach: _currentSession })); } - if (_term) _term.focus(); + // Send current terminal dimensions — fit() may have run before the WS + // was open, so the resize message was lost. Re-send now so muxterm + // SIGWINCHs the PTY to the correct size. + if (_term) { + ws.send(JSON.stringify({ resize: { cols: _term.cols, rows: _term.rows } })); + } + _focusTermInput(); }); ws.addEventListener('message', function(e) { @@ -107,7 +129,15 @@ function _openMuxtermSocket() { try { var msg = JSON.parse(e.data); if (msg.attached) { - // Session attached confirmation + // Session attached — PTY is alive. Send current terminal dimensions + // so muxterm SIGWINCHs the PTY to match our canvas. This is the + // single reliable place to ensure correct sizing: the WS is open, + // the session is attached, and the PTY is ready to receive SIGWINCH. + if (_term && _ws && _ws.readyState === WebSocket.OPEN) { + // Re-fit in case container dimensions changed since last fit() + if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} + _ws.send(JSON.stringify({ resize: { cols: _term.cols, rows: _term.rows } })); + } } else if (msg.error) { console.warn('muxterm error:', msg.error); } else if (msg.exited) { @@ -246,7 +276,7 @@ function _closeSearch() { var bar = document.getElementById('terminal-search-bar'); if (bar) bar.classList.add('hidden'); if (_searchAddon) _searchAddon.clearDecorations(); - if (_term) _term.focus(); + _focusTermInput(); } function _searchNext() { @@ -268,12 +298,12 @@ function _searchPrev() { * @param {number} [fontSize=14] */ function openTerminal(sessionName, remoteId, fontSize) { - _currentSession = null; _reconnectAttempts = 0; - if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; } - if (_ws) { _ws.close(); _ws = null; } + // Set session name immediately so WS open/close handlers see the right value. + // Do NOT close an existing WS here — if it's alive we'll reuse it by sending + // a new {"attach"} message, which is faster than reconnecting. _currentSession = sessionName; var container = document.getElementById('terminal-container'); @@ -287,7 +317,11 @@ function openTerminal(sessionName, remoteId, fontSize) { // Create terminal if not already present if (!_term) { createTerminal(fontSize); + _container = container; _term.open(container); + // NOTE: no click handler here — ghostty-web's canvas mousedown already calls + // textarea.focus() internally. Adding our own click → _term.focus() would + // re-focus the container div AFTER the textarea gets focus, breaking input. // ResizeObserver for auto-refit if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; } @@ -333,18 +367,21 @@ function openTerminal(sessionName, remoteId, fontSize) { }); // Clipboard: Ctrl+Shift+C to copy, Ctrl+F to search + // NOTE: ghostty-web's attachCustomKeyEventHandler uses INVERTED semantics vs xterm.js: + // return true → "I handled this key — skip ghostty's normal processing" + // return false → "I didn't handle this — let ghostty process normally" _term.attachCustomKeyEventHandler(function(e) { - if (e.type !== 'keydown') return true; + if (e.type !== 'keydown') return false; // not a keydown, let ghostty decide if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) { var sel = _term.getSelection(); if (sel) _copyToClipboard(sel); - return false; + return true; // we handled it — don't pass to ghostty } if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) { _openSearch(); - return false; + return true; // we handled it — don't pass to ghostty } - return true; + return false; // let ghostty encode and send this key normally }); // Auto-copy selection @@ -354,26 +391,47 @@ function openTerminal(sessionName, remoteId, fontSize) { }); // OSC 52 clipboard bridge (tmux → browser) - _term.parser.registerOscHandler(52, function(data) { - var parts = data.split(';'); - if (parts.length >= 2) { - try { - var text = atob(parts[1]); - _copyToClipboard(text); - } catch (e) {} - } - return true; - }); + // ghostty-web does not expose a parser API (xterm.js-only) — skip gracefully. + if (_term.parser && typeof _term.parser.registerOscHandler === 'function') { + _term.parser.registerOscHandler(52, function(data) { + var parts = data.split(';'); + if (parts.length >= 2) { + try { + var text = atob(parts[1]); + _copyToClipboard(text); + } catch (e) {} + } + return true; + }); + } - // Fit after layout + // Fit after layout — double-rAF ensures the browser has fully computed the + // flex layout after #view-expanded becomes visible. Single rAF can fire + // before layout settles; the second rAF guarantees one full paint cycle. if (_fitAddon) { var fitAddonRef = _fitAddon; - var raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : function(fn) { fn(); }; - raf(function() { - try { fitAddonRef.fit(); } catch (_) {} - setTimeout(function() { - if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} - }, 500); + requestAnimationFrame(function() { + requestAnimationFrame(function() { + window.__muxDbgPre = { + containerW: container.clientWidth, containerH: container.clientHeight, + termCols: _term ? _term.cols : 0, termRows: _term ? _term.rows : 0, + fitProposed: _fitAddon && _fitAddon.proposeDimensions ? _fitAddon.proposeDimensions() : null, + }; + console.log('[muxplex fit PRE]', JSON.stringify(window.__muxDbgPre)); + try { fitAddonRef.fit(); } catch (_) {} + // Expose terminal state for remote diagnostics + window.__muxDbg = { + containerW: container.clientWidth, containerH: container.clientHeight, + termCols: _term ? _term.cols : 0, termRows: _term ? _term.rows : 0, + canvasW: container.querySelector('canvas') ? container.querySelector('canvas').width : 0, + canvasH: container.querySelector('canvas') ? container.querySelector('canvas').height : 0, + canvasCssW: container.querySelector('canvas') ? container.querySelector('canvas').style.width : '', + canvasCssH: container.querySelector('canvas') ? container.querySelector('canvas').style.height : '', + fitProposed: _fitAddon && _fitAddon.proposeDimensions ? _fitAddon.proposeDimensions() : null, + dpr: window.devicePixelRatio + }; + console.log('[muxplex fit]', JSON.stringify(window.__muxDbg)); + }); }); } @@ -408,15 +466,34 @@ function openTerminal(sessionName, remoteId, fontSize) { initVisualViewport(); _initAndroidIMEFix(container); _initMobileToolbar(); + } else { + // Terminal already exists — this is a session switch. + // Reset VT state + clear the visual buffer so old content doesn't overlay new session. + try { _term.reset(); } catch (_) {} + try { _term.clear(); } catch (_) {} + // Double-rAF ensures layout has settled before measuring for fit + requestAnimationFrame(function() { + requestAnimationFrame(function() { + if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} + _focusTermInput(); + }); + }); } - // If WS is already open, send attach; otherwise connect + // If the WebSocket is already live, send a new {"attach"} message immediately — + // no reconnect needed, no token fetch, instant session switch. + // Otherwise open a fresh connection; the WS open handler sends {"attach": _currentSession}. if (_ws && _ws.readyState === WebSocket.OPEN) { _ws.send(JSON.stringify({ attach: sessionName })); } else { connectWebSocket(); } - }); // close _ghosttyReady.then(function() { ... }) + + // Ensure the terminal has keyboard focus after all setup completes. + requestAnimationFrame(function() { _focusTermInput(); }); + }).catch(function(err) { + console.error('[muxplex] terminal init error:', err); + }); // close _ghosttyReady.then(...).catch(...) } // ——— Close terminal ———————————————————————————————————————————————— @@ -516,7 +593,7 @@ function _initMobileToolbar() { _ctrlActive = !_ctrlActive; btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive); if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); } - if (_ctrlActive && _term) _term.focus(); + if (_ctrlActive) _focusTermInput(); return; } if (modifier === 'alt') { @@ -524,7 +601,7 @@ function _initMobileToolbar() { _altActive = !_altActive; btn.classList.toggle('mobile-toolbar__key--active', _altActive); if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); } - if (_altActive && _term) _term.focus(); + if (_altActive) _focusTermInput(); return; } diff --git a/muxplex/main.py b/muxplex/main.py index 872a7ee..c4f5177 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -546,10 +546,38 @@ _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend" # which machine each muxplex instance is running on. _HOSTNAME = socket.gethostname().split(".")[0] -# Canonical version string — sourced from package metadata (same as `app.version` -# and the `doctor` command). Used to append `?v=` to every static-asset -# URL so browsers immediately pick up new code on each release. -_UI_VERSION: str = importlib.metadata.version("muxplex") +# Canonical package version — exposed by app.version, /health, and the `doctor` +# command. Kept separate from the cache-busting token below. +_PACKAGE_VERSION: str = importlib.metadata.version("muxplex") + + +def _compute_ui_version() -> str: + """Return an 8-char hex token derived from frontend file modification times. + + Hashes the *maximum* mtime across all files under the frontend directory. + Any frontend file edit produces a new token, so browsers and CDN edges + (e.g. Cloudflare with ``max-age=14400``) always fetch the updated assets + instead of serving a stale copy with the same ``?v=`` URL. + + Falls back to the package version string if the directory is unreadable + (e.g. frozen / packaged deployments where mtimes are unreliable). + """ + try: + mtimes = [ + f.stat().st_mtime + for f in _FRONTEND_DIR.rglob("*") + if f.is_file() + ] + if mtimes: + return hashlib.md5(str(max(mtimes)).encode()).hexdigest()[:8] + except Exception: + pass + return _PACKAGE_VERSION + + +# Cache-busting token appended as ``?v=`` to every static-asset URL +# served by index_page(). Recomputed at startup from frontend file mtimes. +_UI_VERSION: str = _compute_ui_version() # Matches src="/" and href="/" in served HTML, excluding /api/ URLs. # Used by index_page() to inject cache-busting version query parameters. diff --git a/muxplex/muxterm.py b/muxplex/muxterm.py index c49a8cc..393c89b 100644 --- a/muxplex/muxterm.py +++ b/muxplex/muxterm.py @@ -19,6 +19,8 @@ import logging import os import pathlib import shutil +import signal +import subprocess logger = logging.getLogger(__name__) @@ -29,6 +31,38 @@ logger = logging.getLogger(__name__) _muxterm_process: asyncio.subprocess.Process | None = None _restart_task: asyncio.Task | None = None # type: ignore[type-arg] +# --------------------------------------------------------------------------- +# Port cleanup +# --------------------------------------------------------------------------- + + +def _kill_port(port: int) -> None: + """Kill any process already listening on *port* to avoid bind conflicts. + + Silently no-ops if no process holds the port or if ``fuser`` is unavailable. + This prevents the muxterm crash-loop that occurs when an old process (e.g. + one started manually for testing) still holds the port when the supervisor + tries to (re)start muxterm. + """ + try: + result = subprocess.run( + ["fuser", f"{port}/tcp"], + capture_output=True, + text=True, + timeout=3, + ) + for pid_str in result.stdout.split(): + try: + os.kill(int(pid_str), signal.SIGTERM) + logger.info( + "killed stale process pid=%s holding port %s", pid_str, port + ) + except (ProcessLookupError, ValueError, PermissionError): + pass + except Exception: + pass # fuser unavailable or other transient error — best-effort only + + # --------------------------------------------------------------------------- # Binary discovery # --------------------------------------------------------------------------- @@ -92,15 +126,19 @@ async def start_muxterm( global _muxterm_process, _restart_task binary = _find_muxterm_binary(binary_path) + _kill_port(port) # evict any stale process holding the port + # stdout/stderr=None → muxterm inherits Python's fd 1/2 and its log output + # goes straight to the systemd journal. Using PIPE here with nobody reading + # causes the pipe buffer to fill and muxterm to block on log writes; worse, + # if Python is SIGKILL'd the broken pipe delivers SIGPIPE to muxterm which + # kills it silently (rc=141) so the monitor never restarts it. _muxterm_process = await asyncio.create_subprocess_exec( binary, "--addr", f"127.0.0.1:{port}", "--secret", secret, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, ) logger.info("muxterm started (pid=%s, port=%s)", _muxterm_process.pid, port) @@ -143,19 +181,18 @@ async def _monitor_and_restart( try: binary = _find_muxterm_binary(binary_path) + _kill_port(port) # clear any stale process still holding the port _muxterm_process = await asyncio.create_subprocess_exec( binary, "--addr", f"127.0.0.1:{port}", "--secret", secret, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, ) logger.info( "muxterm restarted (pid=%s, port=%s)", _muxterm_process.pid, port ) - # Reset backoff on successful spawn, increase on repeated failures + # Increase backoff on repeated failures, cap at 5 s. backoff = min(backoff + 1.0, 5.0) except Exception: logger.exception("failed to restart muxterm, retrying in 5s") diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index c3498f5..558f7d2 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -127,7 +127,8 @@ def test_css_terminal_container_min_width(): assert "flex: 1" in block assert "overflow: hidden" in block assert "background: #000" in block - assert "padding: 0 4px" in block + # Note: padding intentionally removed — it caused an 8px gap that prevented + # the terminal canvas from filling the full container width. # ============================================================ @@ -181,13 +182,12 @@ def test_css_session_sidebar_flex_column(): assert "flex-shrink: 0" in block -def test_css_session_sidebar_transition(): - """.session-sidebar must have transition on width and min-width for collapse animation.""" +def test_css_session_sidebar_no_transition(): + """.session-sidebar must NOT have a width/min-width transition (removed to prevent fit() timing jank).""" css = read_css() block = _extract_rule_block(css, ".session-sidebar {") - assert "transition:" in block - assert "width 0.25s ease" in block - assert "min-width 0.25s ease" in block + assert "width 0.25s ease" not in block + assert "min-width 0.25s ease" not in block def test_css_sidebar_collapsed_state(): diff --git a/muxplex/tests/test_main.py b/muxplex/tests/test_main.py index 60d91f6..bedfc37 100644 --- a/muxplex/tests/test_main.py +++ b/muxplex/tests/test_main.py @@ -7,13 +7,11 @@ immediately after each release, rather than serving stale JS/CSS from the HTTP cache. """ -import importlib.metadata - import pytest from bs4 import BeautifulSoup from fastapi.testclient import TestClient -from muxplex.main import app +from muxplex.main import _UI_VERSION, app # --------------------------------------------------------------------------- @@ -87,7 +85,7 @@ def test_index_all_asset_urls_have_version_suffix(client): verifies that the standard HTTP cache is busted on every release by appending a version query parameter to each static asset reference. """ - version = importlib.metadata.version("muxplex") + version = _UI_VERSION soup = _get_index_soup(client) # All