Add typeahead search previews

This commit is contained in:
GitHub Copilot
2026-01-28 21:09:36 +00:00
parent 915c9d1cbd
commit 2f64968231
+214 -2
View File
@@ -8,6 +8,7 @@ import hashlib
import json
import logging
import signal
import time
from pathlib import Path
from typing import TYPE_CHECKING
@@ -84,7 +85,10 @@ class LocalClientConnector(SessionConnector):
class LocalServer:
def mark_route_activity(self, route_key: str) -> None:
now = asyncio.get_event_loop().time()
try:
now = asyncio.get_running_loop().time()
except RuntimeError:
now = time.monotonic()
self._route_last_activity[route_key] = now
# Throttle SSE notifications - max once per 250ms per route
last_notified = self._route_last_sse_notification.get(route_key, 0.0)
@@ -738,8 +742,9 @@ class LocalServer:
h1 {{ margin-bottom: 8px; }}
.subtitle {{ color: #64748b; font-size: 14px; margin-bottom: 16px; }}
.grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }}
.tile {{ background: #1e293b; border: 1px solid #334155; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,0.4); cursor: pointer; }}
.tile {{ background: #1e293b; border: 1px solid #334155; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,0.4); cursor: pointer; transition: border-color 0.15s; }}
.tile:hover {{ border-color: #475569; }}
.tile.selected {{ border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }}
.tile-header {{ padding: 10px 12px; font-weight: bold; border-bottom: 1px solid #334155; display: flex; align-items: center; justify-content: space-between; }}
.tile-title {{ display: flex; align-items: center; gap: 8px; }}
.sparkline {{ opacity: 0.9; }}
@@ -748,18 +753,50 @@ class LocalServer:
.meta {{ padding: 8px 12px; color: #94a3b8; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
a {{ color: inherit; text-decoration: none; }}
.empty {{ color: #64748b; text-align: center; padding: 40px; }}
/* Floating search results panel */
.floating-results {{ position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 400px; max-width: 90vw; max-height: 70vh; overflow-y: auto; background: #1e293b; border: 1px solid #475569; border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); padding: 16px; z-index: 1000; }}
.floating-results.hidden {{ display: none; }}
.floating-results .search-header {{ margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #334155; display: flex; align-items: center; gap: 8px; }}
.floating-results .search-query {{ font-size: 18px; font-weight: bold; color: #3b82f6; }}
.floating-results .result-item {{ display: flex; align-items: center; gap: 12px; padding: 12px; margin: 6px 0; border: 1px solid #334155; border-radius: 6px; cursor: pointer; transition: all 0.15s; }}
.floating-results .result-item:hover, .floating-results .result-item.active {{ background: #334155; border-color: #3b82f6; }}
.floating-results .result-thumb {{ width: 72px; height: 40px; flex: 0 0 auto; border-radius: 4px; border: 1px solid #334155; background: #0b1220; object-fit: contain; }}
.floating-results .result-content {{ display: flex; flex-direction: column; gap: 2px; }}
.floating-results .result-title {{ font-weight: bold; margin-bottom: 4px; }}
.floating-results .result-meta {{ font-size: 12px; color: #94a3b8; }}
.floating-results .no-results {{ color: #64748b; text-align: center; padding: 20px; }}
/* Keyboard indicator */
.key-indicator {{ position: fixed; bottom: 16px; left: 16px; display: flex; gap: 4px; z-index: 1000; }}
.key-box {{ display: inline-flex; align-items: center; justify-content: center; background: #334155; color: #e2e8f0; font-size: 12px; font-weight: bold; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); opacity: 1; transition: opacity 0.3s; }}
.key-box.square {{ width: 28px; height: 28px; }}
.key-box.rectangle {{ padding: 4px 8px; }}
.key-box.fade-out {{ opacity: 0; }}
/* Help hint */
.help-hint {{ position: fixed; bottom: 16px; right: 16px; color: #64748b; font-size: 12px; }}
</style>
</head>
<body>
<h1>Sessions</h1>
<div class="subtitle" id="subtitle"></div>
<div class=\"grid\" id=\"grid\"></div>
<div class=\"floating-results hidden\" id=\"floating-results\"></div>
<div class=\"key-indicator\" id=\"key-indicator\"></div>
<div class=\"help-hint\">Type to search \u2022 \u2191\u2193 to navigate \u2022 Enter to open \u2022 Esc to clear</div>
<script>
let tiles = {tiles_json};
const composeMode = {compose_mode_js};
const dockerWatchMode = {docker_watch_js};
let cardsBySlug = {{}};
// Typeahead search state
let searchQuery = '';
let activeResultIndex = -1;
let filteredResults = [];
const floatingResultsEl = document.getElementById('floating-results');
const keyIndicatorEl = document.getElementById('key-indicator');
const thumbnailCache = {{}};
const THUMBNAIL_TTL_MS = 5000;
function makeTile(tile) {{
const card = document.createElement('div');
card.className = 'tile';
@@ -822,6 +859,181 @@ class LocalServer:
// Initial render
renderTiles();
// Typeahead search functions
function openTile(tile) {{
if (!tile || !tile.slug) return;
window.open(`/?route_key=${{encodeURIComponent(tile.slug)}}`, `webterm-${{tile.slug}}`);
}}
function normalizeText(value) {{
return (value || '').toString().toLowerCase();
}}
function getTileTitle(tile) {{
return tile.name || tile.slug || 'Unknown';
}}
function getTileCommand(tile) {{
return tile.command || '';
}}
function getThumbnailSrc(tile) {{
const slug = tile.slug || '';
if (!slug) return '';
const now = Date.now();
const existing = thumbnailCache[slug];
if (!existing || (now - existing.updatedAt) > THUMBNAIL_TTL_MS) {{
const src = `/screenshot.svg?route_key=${{encodeURIComponent(slug)}}&_t=${{now}}`;
thumbnailCache[slug] = {{ src, updatedAt: now }};
return src;
}}
return existing.src;
}}
function renderFloatingResults() {{
floatingResultsEl.innerHTML = '';
if (searchQuery === '') {{
floatingResultsEl.classList.add('hidden');
activeResultIndex = -1;
filteredResults = [];
// Clear tile selection
Object.values(cardsBySlug).forEach(c => c.classList.remove('selected'));
return;
}}
const query = normalizeText(searchQuery);
filteredResults = tiles.filter(t => {{
if (!t) return false;
const name = normalizeText(t.name);
const command = normalizeText(t.command);
const slug = normalizeText(t.slug);
return name.includes(query) || command.includes(query) || slug.includes(query);
}});
// Build header
const header = document.createElement('div');
header.className = 'search-header';
header.innerHTML = `<span>Search:</span><span class="search-query">${{searchQuery}}</span>`;
floatingResultsEl.appendChild(header);
if (filteredResults.length === 0) {{
const noResults = document.createElement('div');
noResults.className = 'no-results';
noResults.textContent = 'No matches found';
floatingResultsEl.appendChild(noResults);
}} else {{
// Auto-select first (or only) result
if (activeResultIndex < 0 || activeResultIndex >= filteredResults.length) {{
activeResultIndex = 0;
}}
filteredResults.forEach((tile, index) => {{
const item = document.createElement('div');
item.className = 'result-item' + (index === activeResultIndex ? ' active' : '');
const thumb = document.createElement('img');
thumb.className = 'result-thumb';
const title = getTileTitle(tile);
const command = getTileCommand(tile);
const thumbSrc = getThumbnailSrc(tile);
thumb.alt = title;
if (thumbSrc) {{
thumb.src = thumbSrc;
}} else {{
thumb.style.display = 'none';
}}
const content = document.createElement('div');
content.className = 'result-content';
content.innerHTML = `<div class="result-title">${{title}}</div><div class="result-meta">${{command}}</div>`;
item.appendChild(thumb);
item.appendChild(content);
item.onclick = () => openTile(tile);
floatingResultsEl.appendChild(item);
}});
}}
floatingResultsEl.classList.remove('hidden');
updateTileSelection();
}}
function updateTileSelection() {{
// Clear all selections
Object.values(cardsBySlug).forEach(c => c.classList.remove('selected'));
// Highlight selected tile in main grid
if (filteredResults.length > 0 && activeResultIndex >= 0) {{
const selected = filteredResults[activeResultIndex];
if (selected && selected.slug) {{
const card = cardsBySlug[selected.slug];
if (card) card.classList.add('selected');
}}
}}
}}
function showKeyIndicator(key) {{
const arrowKeyMap = {{ ArrowLeft: '\u2190', ArrowRight: '\u2192', ArrowUp: '\u2191', ArrowDown: '\u2193' }};
const keyDisplay = arrowKeyMap[key] || key;
const keyBox = document.createElement('div');
keyBox.className = 'key-box ' + (key.length > 1 ? 'rectangle' : 'square');
keyBox.textContent = keyDisplay;
keyIndicatorEl.appendChild(keyBox);
setTimeout(() => {{
keyBox.classList.add('fade-out');
setTimeout(() => keyBox.remove(), 300);
}}, 1500);
}}
function handleKeydown(event) {{
// Don't interfere with input fields
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return;
showKeyIndicator(event.key);
if (event.key === 'Escape') {{
searchQuery = '';
activeResultIndex = -1;
renderFloatingResults();
return;
}}
if (event.key === 'Backspace') {{
searchQuery = searchQuery.slice(0, -1);
renderFloatingResults();
return;
}}
if (event.key === 'ArrowUp') {{
event.preventDefault();
if (filteredResults.length > 0) {{
activeResultIndex = (activeResultIndex - 1 + filteredResults.length) % filteredResults.length;
renderFloatingResults();
}}
return;
}}
if (event.key === 'ArrowDown') {{
event.preventDefault();
if (filteredResults.length > 0) {{
activeResultIndex = (activeResultIndex + 1) % filteredResults.length;
renderFloatingResults();
}}
return;
}}
if (event.key === 'Enter') {{
if (filteredResults.length > 0 && activeResultIndex >= 0) {{
openTile(filteredResults[activeResultIndex]);
}} else if (filteredResults.length === 1) {{
openTile(filteredResults[0]);
}}
return;
}}
// Regular character input
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {{
searchQuery += event.key.toLowerCase();
renderFloatingResults();
}}
}}
document.addEventListener('keydown', handleKeydown);
// Refresh a single tile's screenshot
function refreshTile(slug) {{
const card = cardsBySlug[slug];