feat: add search (Ctrl+F) and image rendering (Sixel/Kitty) addons
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.
This commit is contained in:
@@ -71,7 +71,16 @@
|
|||||||
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
|
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="terminal-container" class="terminal-container"></div>
|
<div class="terminal-wrapper">
|
||||||
|
<div id="terminal-search-bar" class="terminal-search-bar hidden">
|
||||||
|
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
|
||||||
|
<span id="terminal-search-count" class="terminal-search-count"></span>
|
||||||
|
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)">▲</button>
|
||||||
|
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">▼</button>
|
||||||
|
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="terminal-container" class="terminal-container"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +256,8 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-search@0.13.0/lib/xterm-addon-search.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-image@0.5.0/lib/xterm-addon-image.js"></script>
|
||||||
<script src="/app.js" defer></script>
|
<script src="/app.js" defer></script>
|
||||||
<script src="/terminal.js" defer></script>
|
<script src="/terminal.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -631,6 +631,14 @@ body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -639,6 +647,59 @@ body {
|
|||||||
padding: 0 4px; /* keep text off the side edges */
|
padding: 0 4px; /* keep text off the side edges */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Terminal search bar */
|
||||||
|
.terminal-search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-search-bar.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-search-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-search-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-search-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-search-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-search-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
|
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */
|
||||||
.xterm .xterm-viewport {
|
.xterm .xterm-viewport {
|
||||||
overflow-y: hidden !important;
|
overflow-y: hidden !important;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ let _reconnectTimer = null;
|
|||||||
let _currentSession = null;
|
let _currentSession = null;
|
||||||
let _vpHandler = null;
|
let _vpHandler = null;
|
||||||
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
||||||
|
let _searchAddon = null;
|
||||||
|
|
||||||
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
||||||
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
// 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 ─────────────────────────────────────────────────────────────
|
// ─── Open / close ─────────────────────────────────────────────────────────────
|
||||||
@@ -321,6 +371,12 @@ function openTerminal(sessionName, sourceUrl) {
|
|||||||
return false; // prevent xterm from processing
|
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
|
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);
|
connectWebSocket(sessionName, sourceUrl);
|
||||||
initVisualViewport(); /* defined in Task 14 */
|
initVisualViewport(); /* defined in Task 14 */
|
||||||
}
|
}
|
||||||
@@ -398,8 +499,10 @@ function closeTerminal() {
|
|||||||
_term.dispose();
|
_term.dispose();
|
||||||
_term = null;
|
_term = null;
|
||||||
_fitAddon = null;
|
_fitAddon = null;
|
||||||
|
_searchAddon = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_closeSearch();
|
||||||
_currentSession = null;
|
_currentSession = null;
|
||||||
_reconnectAttempts = 0; // reset backoff on intentional close
|
_reconnectAttempts = 0; // reset backoff on intentional close
|
||||||
}
|
}
|
||||||
@@ -407,6 +510,8 @@ function closeTerminal() {
|
|||||||
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
// ─── Expose to app.js ─────────────────────────────────────────────────────────
|
||||||
window._openTerminal = openTerminal;
|
window._openTerminal = openTerminal;
|
||||||
window._closeTerminal = closeTerminal;
|
window._closeTerminal = closeTerminal;
|
||||||
|
window._openSearch = _openSearch;
|
||||||
|
window._closeSearch = _closeSearch;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// setTerminalFontSize — live font-size update without reconnecting
|
// setTerminalFontSize — live font-size update without reconnecting
|
||||||
|
|||||||
@@ -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');
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1343,3 +1343,31 @@ def test_html_loads_web_links_addon() -> None:
|
|||||||
assert "web-links" in html.lower() or "weblinks" in html.lower(), (
|
assert "web-links" in html.lower() or "weblinks" in html.lower(), (
|
||||||
"Must load xterm-addon-web-links from CDN"
|
"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"
|
||||||
|
|||||||
Reference in New Issue
Block a user