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:
Brian Krabach
2026-04-01 14:59:43 -07:00
parent 2c30ad39a2
commit 7132587847
5 changed files with 227 additions and 1 deletions
+12 -1
View File
@@ -71,7 +71,16 @@
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
</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)">&#9650;</button>
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">&#9660;</button>
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">&times;</button>
</div>
<div id="terminal-container" class="terminal-container"></div>
</div>
</div>
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting&hellip;</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-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-search@0.13.0/lib/xterm-addon-search.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-image@0.5.0/lib/xterm-addon-image.js"></script>
<script src="/app.js" defer></script>
<script src="/terminal.js" defer></script>
</body>
+61
View File
@@ -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;
+105
View File
@@ -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
+21
View File
@@ -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');
});
+28
View File
@@ -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"