Playground: add tab completion for commands and filenames

This commit is contained in:
Sylvestre Ledru
2026-04-04 11:31:05 +02:00
parent 294191f601
commit a3bbcc284d
2 changed files with 86 additions and 0 deletions
+51
View File
@@ -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,
};
+35
View File
@@ -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;