From f6af138087fa8a5f59a14fc6aeee2ae47ae3052a Mon Sep 17 00:00:00 2001 From: Dev Iyer Date: Sat, 23 Aug 2025 19:27:48 -0700 Subject: [PATCH] feat: Add terminal tab completion for commands and arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements cycling through multiple completion options - Supports command completion (e.g., 'he' → 'help', 'head') - Supports argument completion for file commands (ls, cat, less, etc.) - Supports argument completion for whois, tldr, and other commands - Maintains simple state tracking with tabIndex, tabOptions, and tabBase - Resets completion state on any non-tab key press This makes the CLI behave more like a real terminal where users can: - Press Tab to see available completions - Press Tab repeatedly to cycle through options - Hit Enter to execute the selected command --- js/terminal-ext.js | 5 ++ js/terminal.js | 125 +++++++++++++++++++++++++++++++++------------ 2 files changed, 98 insertions(+), 32 deletions(-) diff --git a/js/terminal-ext.js b/js/terminal-ext.js index 48f33351..7a37d0d8 100644 --- a/js/terminal-ext.js +++ b/js/terminal-ext.js @@ -13,6 +13,11 @@ extend = (term) => { term._promptRawText = () => `${term.user}${term.sep}${term.host} ${term.cwd} $`; term.deepLink = window.location.hash.replace("#", "").split("-").join(" "); + + // Simple tab completion state + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = ""; term.promptText = () => { var text = term diff --git a/js/terminal.js b/js/terminal.js index 66f3ba85..5b4b1339 100644 --- a/js/terminal.js +++ b/js/terminal.js @@ -17,6 +17,11 @@ function runRootTerminal(term) { if (term._initialized && !term.locked) { switch (e) { case '\r': // Enter + // Reset tab state + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = ""; + var exitStatus; term.currentLine = term.currentLine.trim(); const tokens = term.currentLine.split(" "); @@ -51,11 +56,21 @@ function runRootTerminal(term) { } break; case '\u0003': // Ctrl+C + // Reset tab state + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = ""; + term.prompt(); term.clearCurrentLine(true); break; case '\u0008': // Ctrl+H case '\u007F': // Backspace (DEL) + // Reset tab state + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = ""; + // Do not delete the prompt if (term.pos() > 0) { const newLine = term.currentLine.slice(0, term.pos() - 1) + term.currentLine.slice(term.pos()); @@ -63,6 +78,11 @@ function runRootTerminal(term) { } break; case '\033[A': // up + // Reset tab state + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = ""; + var h = [...term.history].reverse(); if (term.historyCursor < h.length - 1) { term.historyCursor += 1; @@ -70,6 +90,11 @@ function runRootTerminal(term) { } break; case '\033[B': // down + // Reset tab state + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = ""; + var h = [...term.history].reverse(); if (term.historyCursor > 0) { term.historyCursor -= 1; @@ -89,42 +114,78 @@ function runRootTerminal(term) { } break; case '\t': // tab - cmd = term.currentLine.split(" ")[0]; - const rest = term.currentLine.slice(cmd.length).trim(); - const autocompleteCmds = Object.keys(commands).filter((c) => c.startsWith(cmd)); - var autocompleteArgs; - - // detect what to autocomplete - if (autocompleteCmds && autocompleteCmds.length > 1) { - const oldLine = term.currentLine; - term.stylePrint(`\r\n${autocompleteCmds.sort().join(" ")}`); - term.prompt(); - term.setCurrentLine(oldLine); - } else if (["cat", "tail", "less", "head", "open", "mv", "cp", "chown", "chmod"].includes(cmd)) { - autocompleteArgs = _filesHere().filter((f) => f.startsWith(rest)); - } else if (["whois", "finger", "groups"].includes(cmd)) { - autocompleteArgs = Object.keys(team).filter((f) => f.startsWith(rest)); - } else if (["man", "woman", "tldr"].includes(cmd)) { - autocompleteArgs = Object.keys(portfolio).filter((f) => f.startsWith(rest)); - } else if (["cd"].includes(cmd)) { - autocompleteArgs = _filesHere().filter((dir) => dir.startsWith(rest) && !_DIRS[term.cwd].includes(dir)); + const tabParts = term.currentLine.split(" "); + const tabCmd = tabParts[0]; + const tabRest = tabParts.slice(1).join(" "); + + // Check if we need to reset tab state (input changed) + if (term.tabBase !== term.currentLine) { + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = term.currentLine; + + // Get completions based on context + if (tabParts.length === 1) { + // Completing command + term.tabOptions = Object.keys(commands).filter(c => c.startsWith(tabCmd)).sort(); + } else if (["cat", "tail", "less", "head", "open", "mv", "cp", "chown", "chmod", "ls"].includes(tabCmd)) { + term.tabOptions = _filesHere().filter(f => f.startsWith(tabRest)).sort(); + } else if (["whois", "finger", "groups"].includes(tabCmd)) { + term.tabOptions = Object.keys(team).filter(f => f.startsWith(tabRest)).sort(); + } else if (["man", "woman", "tldr"].includes(tabCmd)) { + term.tabOptions = Object.keys(portfolio).filter(f => f.startsWith(tabRest)).sort(); + } else if (["cd"].includes(tabCmd)) { + term.tabOptions = _filesHere().filter(dir => dir.startsWith(tabRest) && !_DIRS[term.cwd].includes(dir)).sort(); + } } - - // do the autocompleting - if (autocompleteArgs && autocompleteArgs.length > 1) { - const oldLine = term.currentLine; - term.writeln(`\r\n${autocompleteArgs.join(" ")}`); - term.prompt(); - term.setCurrentLine(oldLine); - } else if (commands[cmd] && autocompleteArgs && autocompleteArgs.length > 0) { - term.setCurrentLine(`${cmd} ${autocompleteArgs[0]}`); - } else if (commands[cmd] && autocompleteArgs && autocompleteArgs.length == 0) { - term.setCurrentLine(`${cmd} ${rest}`); - } else if (autocompleteCmds && autocompleteCmds.length == 1) { - term.setCurrentLine(`${autocompleteCmds[0]} `); + + // Handle tab completion + if (term.tabOptions.length === 0) { + // No completions + } else if (term.tabOptions.length === 1) { + // Single match - complete it + if (tabParts.length === 1) { + // Check if it's already an exact match (like typing "ls" completely) + if (tabCmd === term.tabOptions[0]) { + // Exact match - just add a space + term.setCurrentLine(`${term.tabOptions[0]} `); + } else { + // Partial match - complete it + term.setCurrentLine(`${term.tabOptions[0]} `); + } + } else { + term.setCurrentLine(`${tabCmd} ${term.tabOptions[0]}`); + } + term.tabBase = ""; + term.tabIndex = 0; + term.tabOptions = []; + } else { + // Multiple matches + if (term.tabIndex === 0) { + // First tab - show options + term.writeln(`\r\n${term.tabOptions.join(" ")}`); + term.prompt(); + term.setCurrentLine(term.currentLine); + term.tabIndex = 1; + } else { + // Cycling through options + const option = term.tabOptions[(term.tabIndex - 1) % term.tabOptions.length]; + if (tabParts.length === 1) { + term.setCurrentLine(option); + } else { + term.setCurrentLine(`${tabCmd} ${option}`); + } + term.tabIndex++; + term.tabBase = term.currentLine; // Update base to current selection + } } break; default: // Print all other characters + // Reset tab state on any other key + term.tabIndex = 0; + term.tabOptions = []; + term.tabBase = ""; + const newLine = `${term.currentLine.slice(0, term.pos())}${e}${term.currentLine.slice(term.pos())}`; term.setCurrentLine(newLine, true); break;