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"