# Console Command Autocomplete Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add command/cvar autocomplete to the runtime console input on `server_detail.html`, sharing the editor's ranking algorithm via a pure-JS module compiled to a tiny additional bundle, with a vanilla dropdown that does not collide with the existing ArrowUp/Down history recall.

**Architecture:** Extract the editor's inlined ranking logic into a pure ES module `editor-src/vocab-rank.js`. The editor imports it directly; for the console, a second esbuild entry point bundles it into a small `static/vendor/vocab-rank.bundle.js` that exposes `window.__rankVocab`. A new `static/js/console-autocomplete.js` builds a vanilla dropdown (positioned absolutely under the console input), lazy-fetches `srccfg-vocab.json` on first focus, hides the dropdown once the user types past the first token, and binds Tab/Shift+Tab/Esc only — leaving ArrowUp/Down/Enter untouched for `console-history.js`.

**Tech Stack:** Vanilla JS (no framework), esbuild (IIFE bundles), CodeMirror 6 (editor-side only — console is plain `<input>`), HTMX (existing — for form submission and dynamic page-fragment swap), CSS variables defined in `tokens.css`/`editor.css`. Tests use Node's built-in `node:test` runner (no extra deps).

**Reference Spec:** `docs/superpowers/specs/2026-05-17-console-command-autocomplete-design.md`

---

## File Structure

**New files:**
- `l4d2web/scripts/editor-src/vocab-rank.js` — pure ranking module (ES, exports `rankVocab`)
- `l4d2web/scripts/editor-src/vocab-rank-entry.js` — IIFE entry that assigns `rankVocab` to `window.__rankVocab`
- `l4d2web/scripts/editor-src/vocab-rank.test.js` — Node `node:test` unit tests for the ranker
- `l4d2web/l4d2web/static/js/console-autocomplete.js` — vanilla dropdown, lazy fetch, key handling
- `l4d2web/l4d2web/static/css/console-autocomplete.css` — dropdown styling using existing CSS tokens

**Modified files:**
- `l4d2web/scripts/editor-src/autocomplete.js` — replace inlined `rank()` + scoring with `import { rankVocab } from "./vocab-rank.js"`
- `l4d2web/scripts/editor-src/package.json` — add `build:vocab-rank` script; chain into `build`
- `l4d2web/l4d2web/templates/base.html` — add `<script defer>` for `vocab-rank.bundle.js` and `console-autocomplete.js`; add `<link>` for `console-autocomplete.css`

**Build artifacts (regenerated, do not hand-edit):**
- `l4d2web/l4d2web/static/vendor/editor.bundle.js` — rebuilt because `autocomplete.js` changed
- `l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js` — new tiny bundle

---

## Task 1: Extract `rankVocab` into a pure module (TDD)

**Goal:** Move the editor's inlined ranking logic into a standalone, testable, dependency-free function.

**Files:**
- Create: `l4d2web/scripts/editor-src/vocab-rank.js`
- Create: `l4d2web/scripts/editor-src/vocab-rank.test.js`

- [ ] **Step 1: Write the failing test file**

Create `l4d2web/scripts/editor-src/vocab-rank.test.js`:

```javascript
import { test } from "node:test";
import assert from "node:assert/strict";
import { rankVocab } from "./vocab-rank.js";

const vocab = {
  cvars: [
    { name: "sv_cheats", desc: "Allow cheats" },
    { name: "sv_gravity" },
    { name: "mp_friendlyfire", desc: "Toggle FF" },
  ],
  commands: [
    { name: "kick", desc: "Kick a player" },
    { name: "kickall", desc: "Kick everyone" },
    { name: "changelevel", desc: "Change map" },
  ],
};

test("exact match comes first", () => {
  const out = rankVocab("kick", vocab);
  assert.equal(out[0].name, "kick");
  assert.equal(out[1].name, "kickall");
});

test("prefix matches beat substring matches", () => {
  const out = rankVocab("sv_", vocab);
  assert.equal(out[0].name, "sv_cheats");
  assert.equal(out[1].name, "sv_gravity");
  // mp_friendlyfire contains no "sv_" → should not appear
  assert.ok(!out.some(e => e.name === "mp_friendlyfire"));
});

test("substring matches included after prefix matches", () => {
  // "iendly" is a substring of mp_friendlyfire but a prefix of nothing
  const out = rankVocab("iendly", vocab);
  assert.equal(out.length, 1);
  assert.equal(out[0].name, "mp_friendlyfire");
});

test("kind is preserved on each result", () => {
  const out = rankVocab("kick", vocab);
  assert.equal(out[0].kind, "command");
  const sv = rankVocab("sv_cheats", vocab);
  assert.equal(sv[0].kind, "cvar");
});

test("desc is preserved when present", () => {
  const out = rankVocab("kick", vocab);
  assert.equal(out[0].desc, "Kick a player");
});

test("desc is undefined when source had no desc", () => {
  const out = rankVocab("sv_gravity", vocab);
  assert.equal(out[0].desc, undefined);
});

test("results are capped at the configured limit", () => {
  const big = { cvars: [], commands: [] };
  for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` });
  const out = rankVocab("cmd", big, { limit: 50 });
  assert.equal(out.length, 50);
});

test("default limit is 50", () => {
  const big = { cvars: [], commands: [] };
  for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` });
  const out = rankVocab("cmd", big);
  assert.equal(out.length, 50);
});

test("empty query returns no results", () => {
  const out = rankVocab("", vocab);
  assert.equal(out.length, 0);
});

test("case-insensitive match", () => {
  const out = rankVocab("KICK", vocab);
  assert.equal(out[0].name, "kick");
});
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js
```

Expected: FAIL with `Cannot find module './vocab-rank.js'` or `ERR_MODULE_NOT_FOUND`.

- [ ] **Step 3: Create the ranker module**

Create `l4d2web/scripts/editor-src/vocab-rank.js`:

```javascript
// Pure, dependency-free ranking of a vocabulary against a query string.
// Used by both the CodeMirror editor (via autocomplete.js) and the
// runtime console (via the vocab-rank bundle exposed on window).
//
// Score (lower = better):
//   exact match → 0
//   prefix match → 1 + label.length      (shorter prefix matches win)
//   substring match → 10000 + indexOf    (earlier substring beats later)
//   no match → -1 (excluded)

function score(query, label) {
  if (label === query) return 0;
  if (label.startsWith(query)) return 1 + label.length;
  const i = label.indexOf(query);
  if (i !== -1) return 10000 + i;
  return -1;
}

export function rankVocab(query, vocab, { limit = 50 } = {}) {
  if (!query) return [];
  const q = query.toLowerCase();

  const entries = [
    ...vocab.cvars.map(e => ({ ...e, kind: "cvar" })),
    ...vocab.commands.map(e => ({ ...e, kind: "command" })),
  ];

  const scored = [];
  for (const e of entries) {
    const s = score(q, e.name.toLowerCase());
    if (s === -1) continue;
    scored.push([s, e]);
    if (scored.length > limit * 4) break;
  }
  scored.sort((a, b) => a[0] - b[0]);
  return scored.slice(0, limit).map(([, e]) => e);
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js
```

Expected: PASS — 10 tests passing.

- [ ] **Step 5: Commit**

```bash
git add l4d2web/scripts/editor-src/vocab-rank.js \
        l4d2web/scripts/editor-src/vocab-rank.test.js
git commit -m "feat(editor): extract pure rankVocab module + tests"
```

---

## Task 2: Refactor `autocomplete.js` to use the shared ranker

**Goal:** Replace the inlined `rank()` and scoring loop in `autocomplete.js` with a call to `rankVocab`, with no behavior change.

**Files:**
- Modify: `l4d2web/scripts/editor-src/autocomplete.js`

- [ ] **Step 1: Rewrite `autocomplete.js`**

Replace the entire file contents with:

```javascript
import { autocompletion } from "@codemirror/autocomplete";
import { rankVocab } from "./vocab-rank.js";

const WORD_RE = /[A-Za-z0-9_]{2,}/;

export function vocabCompletions(vocab) {
  // vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
  return (context) => {
    const word = context.matchBefore(WORD_RE);
    if (!word || (word.from === word.to && !context.explicit)) return null;

    const ranked = rankVocab(word.text, vocab);
    const options = ranked.map(e => ({
      label: e.name,
      info: e.desc || e.kind,
      type: e.kind === "command" ? "function" : "variable",
    }));
    return { from: word.from, options, validFor: WORD_RE };
  };
}

export function autocompleteExtension(vocab) {
  return autocompletion({
    override: [vocabCompletions(vocab)],
    activateOnTyping: true,
    maxRenderedOptions: 8,
  });
}
```

- [ ] **Step 2: Rebuild the editor bundle**

```bash
cd l4d2web/scripts/editor-src && npm run build
```

Expected: `editor.bundle.js` regenerated in `l4d2web/l4d2web/static/vendor/`. No esbuild warnings or errors.

- [ ] **Step 3: Manually verify editor autocomplete still works (regression check)**

```bash
cd l4d2web && python ../scripts/dev-server.py
```

(Note: per memory, the dev server is `scripts/dev-server.py` at repo root, not `flask run`.) Then in a browser:

1. Open a server-detail page with a config file editor visible, or navigate to any `.cfg` file edit view.
2. In the editor, type `sv_` — autocomplete dropdown appears with cvars (e.g. `sv_cheats`, `sv_gravity`).
3. Type `sv_cheats` exactly — `sv_cheats` is first in the list.
4. Press Tab — completion is accepted.

Stop the dev server (Ctrl+C).

- [ ] **Step 4: Commit**

```bash
git add l4d2web/scripts/editor-src/autocomplete.js \
        l4d2web/l4d2web/static/vendor/editor.bundle.js
git commit -m "refactor(editor): use shared rankVocab in autocomplete"
```

---

## Task 3: Build a standalone ranker bundle for the console

**Goal:** Produce `vocab-rank.bundle.js` — a tiny IIFE that exposes `window.__rankVocab` — so the non-bundled console-autocomplete.js can call the same ranker.

**Files:**
- Create: `l4d2web/scripts/editor-src/vocab-rank-entry.js`
- Modify: `l4d2web/scripts/editor-src/package.json`

- [ ] **Step 1: Create the IIFE entry point**

Create `l4d2web/scripts/editor-src/vocab-rank-entry.js`:

```javascript
import { rankVocab } from "./vocab-rank.js";

// Expose as a global function so plain (non-module) scripts on
// server_detail.html can call window.__rankVocab(query, vocab).
window.__rankVocab = rankVocab;
```

- [ ] **Step 2: Add a build script for it in `package.json`**

Open `l4d2web/scripts/editor-src/package.json` and replace the `"scripts"` block with:

```json
  "scripts": {
    "build:editor": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json",
    "build:vocab-rank": "esbuild vocab-rank-entry.js --bundle --minify --format=iife --outfile=../../l4d2web/static/vendor/vocab-rank.bundle.js",
    "build": "npm run build:editor && npm run build:vocab-rank"
  },
```

- [ ] **Step 3: Run the build**

```bash
cd l4d2web/scripts/editor-src && npm run build
```

Expected: two output files updated/created. Verify with:

```bash
ls -la l4d2web/l4d2web/static/vendor/editor.bundle.js l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js
```

Expected: `vocab-rank.bundle.js` exists (should be ~1-3 KB).

- [ ] **Step 4: Smoke-test the bundle from Node**

Quick check the bundle is well-formed (no syntax errors):

```bash
node -e 'const fs = require("fs"); const code = fs.readFileSync("l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js", "utf8"); new Function("window", code)({}); console.log("ok");'
```

Expected: prints `ok` (means the IIFE parsed and ran).

- [ ] **Step 5: Commit**

```bash
git add l4d2web/scripts/editor-src/vocab-rank-entry.js \
        l4d2web/scripts/editor-src/package.json \
        l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js
git commit -m "feat(editor): build standalone vocab-rank bundle for console"
```

---

## Task 4: Build the console-autocomplete module

**Goal:** Create the vanilla-JS module that renders the dropdown, handles keyboard interaction, and binds to console forms (including HTMX-injected ones).

**Files:**
- Create: `l4d2web/l4d2web/static/js/console-autocomplete.js`

- [ ] **Step 1: Write the module**

Create `l4d2web/l4d2web/static/js/console-autocomplete.js`:

```javascript
// 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 ? `<span class="console-autocomplete-desc">${escapeHtml(e.desc)}</span>` : "";
      return `<div class="console-autocomplete-row ${kindClass}"${selected} role="option" data-idx="${i}"><span class="console-autocomplete-name">${escapeHtml(e.name)}</span>${desc}</div>`;
    }).join("");
    dropdown.innerHTML = rows;
    dropdown.style.display = "block";
    position();
  }

  function escapeHtml(s) {
    return String(s).replace(/[&<>"']/g, c => ({
      "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
    }[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));
```

- [ ] **Step 2: Commit (no template/CSS wire-up yet — module is not yet loaded)**

```bash
git add l4d2web/l4d2web/static/js/console-autocomplete.js
git commit -m "feat(console): add vanilla autocomplete dropdown module"
```

---

## Task 5: Add dropdown stylesheet

**Goal:** Provide minimal CSS so the dropdown is positioned, themed via existing CSS tokens, and visually consistent with the editor's autocomplete popup.

**Files:**
- Create: `l4d2web/l4d2web/static/css/console-autocomplete.css`

- [ ] **Step 1: Write the stylesheet**

Create `l4d2web/l4d2web/static/css/console-autocomplete.css`:

```css
/* Console autocomplete dropdown.
   Positioned absolutely under the console input by JS; visuals match
   the editor's tooltip styling (var(--cm-*) tokens defined in
   tokens.css and editor.css). */

.console-autocomplete-dropdown {
  position: absolute;
  z-index: 1000;
  max-height: calc(8 * 2.4rem);
  overflow-y: auto;
  background-color: var(--cm-bg, #1e1e1e);
  color: var(--cm-fg, #e0e0e0);
  border: 1px solid var(--border-strong, #444);
  border-radius: 4px;
  font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
  font-size: 13px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}

.console-autocomplete-row {
  display: flex;
  align-items: baseline;
  gap: 0.75em;
  padding: 0.3em 0.6em;
  cursor: pointer;
  white-space: nowrap;
}

.console-autocomplete-row[aria-selected="true"] {
  background-color: var(--cm-selection, #264f78);
}

.console-autocomplete-row:hover {
  background-color: var(--cm-selection, #264f78);
}

.console-autocomplete-name {
  font-weight: 600;
}

.console-autocomplete-row.kind-cvar .console-autocomplete-name {
  color: var(--cm-keyword, #569cd6);
}

.console-autocomplete-row.kind-command .console-autocomplete-name {
  color: var(--cm-string, #ce9178);
}

.console-autocomplete-desc {
  color: var(--fg-muted, #888);
  font-size: 0.9em;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 40em;
}
```

- [ ] **Step 2: Commit**

```bash
git add l4d2web/l4d2web/static/css/console-autocomplete.css
git commit -m "feat(console): add autocomplete dropdown stylesheet"
```

---

## Task 6: Wire up in `base.html`

**Goal:** Load the ranker bundle, the console-autocomplete script, and the stylesheet — placed alongside the existing `console-history.js` tag so loading order matches.

**Files:**
- Modify: `l4d2web/l4d2web/templates/base.html`

- [ ] **Step 1: Read the current head/body script section**

Open `l4d2web/l4d2web/templates/base.html` and find the line that currently loads `console-history.js`:

```html
    <script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
```

- [ ] **Step 2: Add the new tags directly after it**

Add immediately after the `console-history.js` script tag:

```html
    <script defer src="{{ url_for('static', filename='vendor/vocab-rank.bundle.js') }}"></script>
    <script defer src="{{ url_for('static', filename='js/console-autocomplete.js') }}"></script>
```

And add to the `<head>` section (alongside other `<link rel="stylesheet">` tags — search for existing ones in `base.html`):

```html
    <link rel="stylesheet" href="{{ url_for('static', filename='css/console-autocomplete.css') }}">
```

- [ ] **Step 3: Sanity-check the template renders without syntax errors**

```bash
cd l4d2web && python -c "from l4d2web.app import create_app; create_app(); print('ok')"
```

Expected: prints `ok` (Flask app boots; templates are valid Jinja).

- [ ] **Step 4: Commit**

```bash
git add l4d2web/l4d2web/templates/base.html
git commit -m "feat(console): wire up autocomplete bundle + stylesheet in base.html"
```

---

## Task 7: End-to-end smoke test

**Goal:** Verify the full feature works in the browser against the dev server.

- [ ] **Step 1: Start the dev server**

```bash
cd l4d2web && python ../scripts/dev-server.py
```

Expected: server starts on `http://localhost:5000` (or whatever the script reports). `LEFT4ME_ROOT` is auto-set to `.tmp/dev-server` and seeded with demo content per memory.

- [ ] **Step 2: Run through the smoke-test checklist in a browser**

Open a server-detail page (one of the demo servers seeded by the dev server). Then verify each:

1. **Vocab fetch is lazy.** Open DevTools → Network → filter `srccfg-vocab`. Reload page. **Expected:** no request yet.
2. **Click into the console input.** **Expected:** one `srccfg-vocab.json` request fires.
3. **Type `sv_`.** **Expected:** dropdown appears showing cvars starting with `sv_`. Top row highlighted.
4. **Press Tab.** **Expected:** first token replaced with the highlighted suggestion (e.g. `sv_cheats`). Dropdown updates with matches for the new query.
5. **Press Shift+Tab.** **Expected:** highlight moves up; or wraps to bottom if at top.
6. **Press Esc.** **Expected:** dropdown closes. Input value unchanged.
7. **Type a space then `god`.** **Expected:** dropdown stays hidden (we're past the first token).
8. **Press ArrowUp.** **Expected:** history recall works — input is replaced with a previously submitted command. No interference from autocomplete.
9. **Clear the input. Type `sv_che`.** Verify `sv_cheats` is highlighted in the dropdown. **Press Enter.** **Expected:** the server console receives `sv_che` (the typed text), not `sv_cheats`. Confirm in the console transcript.
10. **Refocus the input.** **Expected:** no second `srccfg-vocab.json` request (cached in module-scope promise).
11. **Click on a dropdown row with the mouse.** **Expected:** that row's command is inserted into the input.
12. **Editor regression check.** Navigate to a `.cfg` file in the editor (files view). Type `sv_`. **Expected:** editor's autocomplete still works exactly as before.

If all 12 pass, the feature is complete.

- [ ] **Step 3: Stop dev server (Ctrl+C) and confirm final commit state**

```bash
git log --oneline -10
git status
```

Expected: 6 new commits ahead of the pre-feature state; working tree clean.

---

## Verification Summary

- **Unit tests:** `cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js` — 10 passing tests for the ranker.
- **Manual editor regression:** Editor autocomplete still works on `.cfg` files.
- **Manual console smoke test:** 12-point checklist in Task 7 Step 2.
- **No new runtime JS dependencies** added (vocab-rank.test.js uses only `node:test` + `node:assert/strict`, which are built into Node ≥ 18).

## What's Explicitly Out of Scope

- Argument value completion (player names, map names) — would require runtime data, not in `srccfg-vocab.json`.
- Fuzzy / typo-tolerant matching.
- Replacing CodeMirror's editor dropdown with a custom widget.
- Cross-browser e2e automation (no Playwright/Cypress in the codebase; not adding one as part of this work).
