// Tab-completion + suggestion dropdown for the console command input. // - Ghost text shows the top match inline (Tab to accept) // - A dropdown list shows up to N matches with their argument signature // (MC-style: [optional] for choices) // - Click a list item to insert it; arrow keys + Enter also navigate the list // when it's open "use strict"; import { state } from "./state.js"; import { escapeHtml } from "./api.js"; const COMMANDS = [ "help", "list", "say", "tell", "msg", "me", "w", "op", "deop", "whitelist", "ban", "ban-ip", "pardon", "pardon-ip", "banlist", "kick", "tp", "teleport", "give", "clear", "kill", "gamemode", "gamerule", "difficulty", "weather", "time", "seed", "spawnpoint", "setworldspawn", "save-all", "save-on", "save-off", "stop", "reload", "xp", "experience", "effect", "enchant", "summon", "data", "execute", "fill", "setblock", "locate", "tag", "ftbchunks", "ftbteams", "chunky", "kubejs", "kjs", ]; const SUBCOMMANDS = { whitelist: ["add", "remove", "list", "reload", "on", "off"], gamemode: ["survival", "creative", "adventure", "spectator"], weather: ["clear", "rain", "thunder"], difficulty: ["peaceful", "easy", "normal", "hard"], time: ["set", "add", "query"], chunky: ["start", "cancel", "pause", "continue", "world", "shape", "center", "radius", "force_load", "force_unload", "trim", "help"], ftbchunks: ["claim", "unclaim", "load", "unload", "admin"], }; const TAKES_PLAYER_AT = { "op": 1, "deop": 1, "tp": 1, "teleport": 1, "kick": 1, "ban": 1, "pardon": 1, "kill": 1, "tell": 1, "msg": 1, "w": 1, "give": 1, "clear": 1, "effect": 1, "enchant": 1, "xp": 1, "experience": 1, "whitelist add": 2, "whitelist remove": 2, "gamemode survival": 2, "gamemode creative": 2, "gamemode adventure": 2, "gamemode spectator": 2, }; // MC-style argument signatures for each command. Shown as a hint after the name // in the suggestion list. [optional] for enum choices. const SIGNATURES = { help: "[command]", list: "", say: "", tell: " ", msg: " ", me: "", w: " ", op: "", deop: "", whitelist: "", "whitelist add": "", "whitelist remove": "", "whitelist list": "", "whitelist on": "", "whitelist off": "", "whitelist reload": "", ban: " [reason…]", "ban-ip": " [reason…]", pardon: "", "pardon-ip": "", banlist: "[ips|players]", kick: " [reason…]", tp: " [destination]", teleport: " [destination]", give: " [count]", clear: "[player] [item]", kill: "[target]", gamemode: " [player]", "gamemode survival": "[player]", "gamemode creative": "[player]", "gamemode adventure": "[player]", "gamemode spectator": "[player]", gamerule: " [value]", difficulty: "", weather: " [duration]", time: " ", seed: "", spawnpoint: "[player] [pos]", setworldspawn: "[pos]", "save-all": "[flush]", "save-on": "", "save-off": "", stop: "", reload: "", xp: " [player]", experience: " ", effect: " ", enchant: " [level]", summon: " [pos]", fill: " ", setblock: " ", locate: " ", tag: " [tag]", chunky: "", "chunky start": "[world] [shape] [center_x] [center_z] [radius]", "chunky cancel": "", "chunky pause": "", "chunky continue": "", "chunky world": "", "chunky shape": "", "chunky center": " ", "chunky radius": "", "chunky trim": "[world] [radius] [trim_radius]", ftbchunks: "", ftbteams: "", kubejs: "", kjs: "", }; const MAX_SUGGESTIONS = 8; let activeIndex = 0; let currentSuggestions = []; export function setupAutocomplete() { const cmdInput = document.getElementById("cmdInput"); const cmdGhost = document.getElementById("cmdGhost"); const cmdSuggest = document.getElementById("cmdSuggest"); function refresh() { const v = cmdInput.value; currentSuggestions = computeAllSuggestions(v).slice(0, MAX_SUGGESTIONS); activeIndex = 0; // Inline ghost = top suggestion (only if it extends what they typed) const top = currentSuggestions[0]; if (top && top.text.startsWith(v) && top.text !== v) { const suffix = top.text.substring(v.length); cmdGhost.innerHTML = `${escapeHtml(v)}${escapeHtml(suffix)}`; cmdInput.dataset.suggestion = top.text; } else { cmdGhost.innerHTML = ""; cmdInput.dataset.suggestion = ""; } renderList(cmdSuggest, currentSuggestions); } cmdInput.addEventListener("input", refresh); cmdInput.addEventListener("focus", refresh); cmdInput.addEventListener("blur", () => { // Delay so a click on a list item registers before we hide setTimeout(() => cmdSuggest.classList.remove("show"), 150); }); cmdInput.addEventListener("keydown", e => { if (e.key === "Tab") { const sug = currentSuggestions[activeIndex]; if (sug) { e.preventDefault(); cmdInput.value = sug.text + " "; refresh(); } } else if (e.key === "Escape") { cmdGhost.innerHTML = ""; cmdInput.dataset.suggestion = ""; cmdSuggest.classList.remove("show"); } else if (e.key === "ArrowDown" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) { e.preventDefault(); activeIndex = (activeIndex + 1) % currentSuggestions.length; highlightActive(cmdSuggest); } else if (e.key === "ArrowUp" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) { e.preventDefault(); activeIndex = (activeIndex - 1 + currentSuggestions.length) % currentSuggestions.length; highlightActive(cmdSuggest); } // Note: Enter is handled by console.js (sends the command) }); cmdSuggest.addEventListener("mousedown", e => { // mousedown (not click) so we beat the input blur handler const item = e.target.closest(".suggest-item"); if (!item) return; e.preventDefault(); const idx = parseInt(item.dataset.idx, 10); const sug = currentSuggestions[idx]; if (sug) { cmdInput.value = sug.text + " "; cmdInput.focus(); refresh(); } }); } function highlightActive(listEl) { [...listEl.querySelectorAll(".suggest-item")].forEach((el, i) => { el.classList.toggle("active", i === activeIndex); if (i === activeIndex) el.scrollIntoView({ block: "nearest" }); }); } function renderList(listEl, suggestions) { if (suggestions.length === 0) { listEl.classList.remove("show"); listEl.innerHTML = ""; return; } listEl.innerHTML = suggestions.map((s, i) => { const args = s.args ? `${escapeHtml(s.args)}` : ""; return `
` + `${escapeHtml(s.text)}${args}
`; }).join(""); listEl.classList.add("show"); } // Returns an array of {text, args} suggestions ordered by relevance. // args is the MC-style hint shown next to the name. function computeAllSuggestions(input) { if (!input) return []; const stripped = input.startsWith("/") ? input.substring(1) : input; const tokens = stripped.split(" "); const partial = tokens[tokens.length - 1].toLowerCase(); const completed = tokens.slice(0, -1); const prefix = input.startsWith("/") ? "/" : ""; // First token: command name if (completed.length === 0) { const matches = COMMANDS.filter(c => c.startsWith(partial)).sort(); return matches.map(name => ({ text: prefix + name, args: SIGNATURES[name] ?? "", })); } // Subcommands const headLower = completed[0].toLowerCase(); if (completed.length === 1 && SUBCOMMANDS[headLower]) { const subs = SUBCOMMANDS[headLower].filter(s => s.startsWith(partial)); return subs.map(sub => ({ text: prefix + [...completed, sub].join(" "), args: SIGNATURES[`${headLower} ${sub}`] ?? "", })); } // Player-name positions const cmdKey = completed.join(" ").toLowerCase(); const playerSlot = TAKES_PLAYER_AT[cmdKey]; if (playerSlot !== undefined && tokens.length === playerSlot + 1) { const matches = state.knownPlayers.filter(p => p.toLowerCase().startsWith(partial)); return matches.map(p => ({ text: prefix + [...completed, p].join(" "), args: "", })); } // No structured suggestion at this position -- but still show the current // command's signature as a contextual hint const sig = SIGNATURES[cmdKey]; if (sig) { return [{ text: input, args: sig }]; } return []; }