left4me/docs/superpowers/plans/2026-05-17-console-command-autocomplete.md
mwiegand 2875993339
docs(console): add implementation plan for console autocomplete
Bite-sized, TDD-style plan for the spec at
docs/superpowers/specs/2026-05-17-console-command-autocomplete-design.md.
Seven tasks (rankVocab extraction → second esbuild target → vanilla
dropdown module → stylesheet → wire-up → smoke test). Will be executed
task-by-task via subagent-driven-development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:33:56 +02:00

25 KiB

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:

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
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:

// 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
cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js

Expected: PASS — 10 tests passing.

  • Step 5: Commit
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:

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
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)
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
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:

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:

  "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
cd l4d2web/scripts/editor-src && npm run build

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

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):

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
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:

// 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)
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:

/* 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
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:

    <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:

    <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):

    <link rel="stylesheet" href="{{ url_for('static', filename='css/console-autocomplete.css') }}">
  • Step 3: Sanity-check the template renders without syntax errors
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
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
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
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).