From a3bbcc284de0e9c51cf60a18ea0a4d88083094cb Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 4 Apr 2026 11:31:05 +0200 Subject: [PATCH] Playground: add tab completion for commands and filenames --- static/js/wasm-terminal.js | 51 +++++++++++++++++++++++++++++++ static/js/wasm-terminal.test.html | 35 +++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/static/js/wasm-terminal.js b/static/js/wasm-terminal.js index 7b7c18496..936c80baf 100644 --- a/static/js/wasm-terminal.js +++ b/static/js/wasm-terminal.js @@ -579,6 +579,40 @@ function redrawInput() { if (moveBack > 0) terminal.write(`\x1b[${moveBack}D`); } +/** + * Compute tab-completion result (pure function, no terminal side-effects). + * Returns { buffer, cursor, completed, candidates }. + */ +function tabComplete(buffer, cursor) { + const before = buffer.slice(0, cursor); + const after = buffer.slice(cursor); + const parts = before.split(/\s+/); + const word = parts[parts.length - 1]; + const isCommand = parts.length <= 1; + + const builtins = ["help", "clear", "cd", "locale"]; + const candidates = isCommand + ? AVAILABLE_COMMANDS.concat(builtins).filter(c => c.startsWith(word)) + : Object.keys(SAMPLE_FILES).filter(f => f.startsWith(word)); + + if (candidates.length === 1) { + const suffix = candidates[0].slice(word.length) + " "; + return { buffer: before + suffix + after, cursor: cursor + suffix.length, completed: true, candidates }; + } + if (candidates.length > 1) { + let common = candidates[0]; + for (const c of candidates) { + while (!c.startsWith(common)) common = common.slice(0, -1); + } + if (common.length > word.length) { + const suffix = common.slice(word.length); + return { buffer: before + suffix + after, cursor: cursor + suffix.length, completed: true, candidates }; + } + return { buffer, cursor, completed: false, candidates }; + } + return { buffer, cursor, completed: false, candidates: [] }; +} + async function handleInput(data) { for (let i = 0; i < data.length; i++) { const ch = data[i]; @@ -662,6 +696,22 @@ async function handleInput(data) { continue; } + if (code === 9) { // Tab — completion + const result = tabComplete(inputBuffer, cursorPos); + if (result.completed) { + inputBuffer = result.buffer; + cursorPos = result.cursor; + redrawInput(); + } else if (result.candidates.length > 1) { + terminal.write("\r\n" + result.candidates.join(" ") + "\r\n"); + prompt(); + terminal.write(inputBuffer); + const back = inputBuffer.length - cursorPos; + if (back > 0) terminal.write(`\x1b[${back}D`); + } + continue; + } + if (code === 21) { // Ctrl+U inputBuffer = ""; cursorPos = 0; @@ -806,4 +856,5 @@ window._uutilsTestInternals = { LOCALE_SHORTCUTS, SAMPLE_FILES, AVAILABLE_COMMANDS, + tabComplete, }; diff --git a/static/js/wasm-terminal.test.html b/static/js/wasm-terminal.test.html index 1ecd1606a..700e583e1 100644 --- a/static/js/wasm-terminal.test.html +++ b/static/js/wasm-terminal.test.html @@ -528,6 +528,41 @@ async function runTests() { assert("ja shortcut", T.LOCALE_SHORTCUTS["ja"], "ja-JP"); assert("pt shortcut", T.LOCALE_SHORTCUTS["pt"], "pt-BR"); + // ===== Tab completion ===== + section("Tab completion"); + + const tc = T.tabComplete; + + // Single command match + let r = tc("fac", 3); + assert("complete 'fac' -> 'factor '", r.buffer, "factor "); + assert("complete 'fac' completed", r.completed, true); + + // Single filename match + r = tc("cat na", 6); + assert("complete 'cat na' -> 'cat names.txt '", r.buffer, "cat names.txt "); + assert("complete filename completed", r.completed, true); + + // Multiple matches with common prefix + r = tc("sha2", 4); + assert("complete 'sha2' partial -> 'sha2'", r.buffer.startsWith("sha2"), true); + assert("complete 'sha2' has candidates", r.candidates.length > 1, true); + + // No match + r = tc("zzzzz", 5); + assert("complete 'zzzzz' no match", r.candidates.length, 0); + assert("complete 'zzzzz' not completed", r.completed, false); + + // Builtin completion + r = tc("hel", 3); + assert("complete 'hel' -> 'help '", r.buffer, "help "); + + // Multiple command matches shown + r = tc("sh", 2); + assert("complete 'sh' multiple candidates", r.candidates.length > 1, true); + assert("complete 'sh' includes shuf", r.candidates.includes("shuf"), true); + assert("complete 'sh' includes shred", r.candidates.includes("shred"), true); + // ===== Summary ===== const summary = document.getElementById("summary"); const total = passed + failed;