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,8 +71,17 @@
|
||||
<button id="sidebar-new-session-btn" class="sidebar-new-btn">+ New</button>
|
||||
</div>
|
||||
</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 id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting…</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user