diff --git a/l4d2web/l4d2web/static/js/console-autocomplete.js b/l4d2web/l4d2web/static/js/console-autocomplete.js
new file mode 100644
index 0000000..3dd9ca0
--- /dev/null
+++ b/l4d2web/l4d2web/static/js/console-autocomplete.js
@@ -0,0 +1,183 @@
+// console-autocomplete.js
+// Vanilla dropdown autocomplete for [data-console-form] inputs.
+// Reads ranked completions from window.__rankVocab (loaded via
+// vocab-rank.bundle.js). Owns: Tab, Shift+Tab, Esc, mouse events.
+// Leaves: ArrowUp, ArrowDown, Enter (console-history.js owns those).
+//
+// First-token only: the dropdown is hidden as soon as the cursor
+// is past the first space in the input.
+
+const VOCAB_URL = "/static/data/srccfg-vocab.json";
+const MAX_RENDERED = 8;
+let vocabPromise = null;
+
+function loadVocab() {
+ if (vocabPromise) return vocabPromise;
+ vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
+ .then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
+ .catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
+ return vocabPromise;
+}
+
+function firstTokenSlice(value, caret) {
+ // Returns the substring [0, end-of-first-token) if the caret is
+ // within the first token; otherwise null.
+ const spaceIdx = value.indexOf(" ");
+ if (spaceIdx === -1) {
+ return { token: value, from: 0, to: value.length };
+ }
+ if (caret > spaceIdx) return null;
+ return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
+}
+
+function bindConsoleAutocomplete(form) {
+ if (form.dataset.consoleAutocompleteBound === "true") return;
+ form.dataset.consoleAutocompleteBound = "true";
+
+ const input = form.querySelector("input[name='command']");
+ if (!input) return;
+
+ // --- Dropdown DOM (created lazily on first show) ---
+ let dropdown = null;
+ let items = []; // current ranked items
+ let highlightIdx = 0; // index of currently-highlighted row
+ let vocab = null;
+
+ function ensureDropdown() {
+ if (dropdown) return dropdown;
+ dropdown = document.createElement("div");
+ dropdown.className = "console-autocomplete-dropdown";
+ dropdown.setAttribute("role", "listbox");
+ dropdown.style.display = "none";
+ document.body.appendChild(dropdown);
+ return dropdown;
+ }
+
+ function position() {
+ if (!dropdown) return;
+ const rect = input.getBoundingClientRect();
+ dropdown.style.left = `${rect.left + window.scrollX}px`;
+ dropdown.style.top = `${rect.bottom + window.scrollY}px`;
+ dropdown.style.minWidth = `${rect.width}px`;
+ }
+
+ function close() {
+ if (!dropdown) return;
+ dropdown.style.display = "none";
+ items = [];
+ highlightIdx = 0;
+ }
+
+ function render() {
+ ensureDropdown();
+ if (items.length === 0) { close(); return; }
+ const rows = items.slice(0, MAX_RENDERED).map((e, i) => {
+ const selected = i === highlightIdx ? " aria-selected='true'" : "";
+ const kindClass = e.kind === "command" ? "kind-command" : "kind-cvar";
+ const desc = e.desc ? `${escapeHtml(e.desc)}` : "";
+ return `
${escapeHtml(e.name)}${desc}
`;
+ }).join("");
+ dropdown.innerHTML = rows;
+ dropdown.style.display = "block";
+ position();
+ }
+
+ function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({
+ "&": "&", "<": "<", ">": ">", '"': """, "'": "'",
+ }[c]));
+ }
+
+ function acceptHighlighted() {
+ if (items.length === 0) return;
+ const chosen = items[highlightIdx];
+ const slice = firstTokenSlice(input.value, input.selectionStart || 0);
+ if (!slice) return;
+ const before = input.value.slice(0, slice.from);
+ const after = input.value.slice(slice.to);
+ input.value = before + chosen.name + after;
+ // Place caret at end of inserted name
+ const caret = before.length + chosen.name.length;
+ input.setSelectionRange(caret, caret);
+ recompute();
+ }
+
+ function recompute() {
+ if (!vocab) return;
+ const slice = firstTokenSlice(input.value, input.selectionStart || 0);
+ if (!slice || !slice.token) { close(); return; }
+ items = window.__rankVocab(slice.token, vocab);
+ if (items.length === 0) { close(); return; }
+ highlightIdx = 0;
+ render();
+ }
+
+ // --- Lazy vocab fetch on first focus ---
+ input.addEventListener("focus", async () => {
+ if (!vocab) {
+ vocab = await loadVocab();
+ }
+ }, { once: true });
+
+ input.addEventListener("input", () => {
+ if (!vocab) return; // fetch may not have resolved yet; next input will recompute
+ recompute();
+ });
+
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "Tab" && !event.shiftKey) {
+ if (items.length > 0) {
+ event.preventDefault();
+ acceptHighlighted();
+ }
+ } else if (event.key === "Tab" && event.shiftKey) {
+ if (items.length > 0) {
+ event.preventDefault();
+ highlightIdx = (highlightIdx - 1 + Math.min(items.length, MAX_RENDERED))
+ % Math.min(items.length, MAX_RENDERED);
+ render();
+ }
+ } else if (event.key === "Escape") {
+ if (dropdown && dropdown.style.display !== "none") {
+ event.preventDefault();
+ close();
+ }
+ }
+ // ArrowUp/ArrowDown/Enter intentionally NOT handled here.
+ });
+
+ input.addEventListener("blur", () => {
+ // Delay close so a click on a dropdown row can fire first.
+ setTimeout(close, 100);
+ });
+
+ // Mouse click on a row → accept that row.
+ document.addEventListener("mousedown", (event) => {
+ if (!dropdown || dropdown.style.display === "none") return;
+ const row = event.target.closest(".console-autocomplete-row");
+ if (!row || !dropdown.contains(row)) return;
+ event.preventDefault();
+ highlightIdx = parseInt(row.dataset.idx, 10) || 0;
+ acceptHighlighted();
+ input.focus();
+ });
+
+ // HTMX form submission clears the input; close on submit.
+ form.addEventListener("htmx:beforeRequest", close);
+
+ // Reposition on resize/scroll while dropdown is open.
+ window.addEventListener("resize", () => { if (dropdown && dropdown.style.display !== "none") position(); });
+ window.addEventListener("scroll", () => { if (dropdown && dropdown.style.display !== "none") position(); }, true);
+}
+
+function bindAll(root) {
+ if (!root) return;
+ const scope = root.matches && root.matches("[data-console-form]") ? [root] : [];
+ if (root.querySelectorAll) {
+ root.querySelectorAll("[data-console-form]").forEach((el) => scope.push(el));
+ }
+ scope.forEach(bindConsoleAutocomplete);
+}
+
+document.addEventListener("DOMContentLoaded", () => bindAll(document));
+document.addEventListener("htmx:load", (event) => bindAll(event.detail.elt));