Skip to content

Commit fb59a36

Browse files
authored
Merge pull request #101 from midaz/feature/tab-completion
feat: Add terminal tab completion for commands and arguments
2 parents 85fbcbb + f6af138 commit fb59a36

File tree

2 files changed

+98
-32
lines changed

2 files changed

+98
-32
lines changed

js/terminal-ext.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ extend = (term) => {
1313
term._promptRawText = () =>
1414
`${term.user}${term.sep}${term.host} ${term.cwd} $`;
1515
term.deepLink = window.location.hash.replace("#", "").split("-").join(" ");
16+
17+
// Simple tab completion state
18+
term.tabIndex = 0;
19+
term.tabOptions = [];
20+
term.tabBase = "";
1621

1722
term.promptText = () => {
1823
var text = term

js/terminal.js

Lines changed: 93 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ function runRootTerminal(term) {
1717
if (term._initialized && !term.locked) {
1818
switch (e) {
1919
case '\r': // Enter
20+
// Reset tab state
21+
term.tabIndex = 0;
22+
term.tabOptions = [];
23+
term.tabBase = "";
24+
2025
var exitStatus;
2126
term.currentLine = term.currentLine.trim();
2227
const tokens = term.currentLine.split(" ");
@@ -51,25 +56,45 @@ function runRootTerminal(term) {
5156
}
5257
break;
5358
case '\u0003': // Ctrl+C
59+
// Reset tab state
60+
term.tabIndex = 0;
61+
term.tabOptions = [];
62+
term.tabBase = "";
63+
5464
term.prompt();
5565
term.clearCurrentLine(true);
5666
break;
5767
case '\u0008': // Ctrl+H
5868
case '\u007F': // Backspace (DEL)
69+
// Reset tab state
70+
term.tabIndex = 0;
71+
term.tabOptions = [];
72+
term.tabBase = "";
73+
5974
// Do not delete the prompt
6075
if (term.pos() > 0) {
6176
const newLine = term.currentLine.slice(0, term.pos() - 1) + term.currentLine.slice(term.pos());
6277
term.setCurrentLine(newLine, true)
6378
}
6479
break;
6580
case '\033[A': // up
81+
// Reset tab state
82+
term.tabIndex = 0;
83+
term.tabOptions = [];
84+
term.tabBase = "";
85+
6686
var h = [...term.history].reverse();
6787
if (term.historyCursor < h.length - 1) {
6888
term.historyCursor += 1;
6989
term.setCurrentLine(h[term.historyCursor], false);
7090
}
7191
break;
7292
case '\033[B': // down
93+
// Reset tab state
94+
term.tabIndex = 0;
95+
term.tabOptions = [];
96+
term.tabBase = "";
97+
7398
var h = [...term.history].reverse();
7499
if (term.historyCursor > 0) {
75100
term.historyCursor -= 1;
@@ -89,42 +114,78 @@ function runRootTerminal(term) {
89114
}
90115
break;
91116
case '\t': // tab
92-
cmd = term.currentLine.split(" ")[0];
93-
const rest = term.currentLine.slice(cmd.length).trim();
94-
const autocompleteCmds = Object.keys(commands).filter((c) => c.startsWith(cmd));
95-
var autocompleteArgs;
96-
97-
// detect what to autocomplete
98-
if (autocompleteCmds && autocompleteCmds.length > 1) {
99-
const oldLine = term.currentLine;
100-
term.stylePrint(`\r\n${autocompleteCmds.sort().join(" ")}`);
101-
term.prompt();
102-
term.setCurrentLine(oldLine);
103-
} else if (["cat", "tail", "less", "head", "open", "mv", "cp", "chown", "chmod"].includes(cmd)) {
104-
autocompleteArgs = _filesHere().filter((f) => f.startsWith(rest));
105-
} else if (["whois", "finger", "groups"].includes(cmd)) {
106-
autocompleteArgs = Object.keys(team).filter((f) => f.startsWith(rest));
107-
} else if (["man", "woman", "tldr"].includes(cmd)) {
108-
autocompleteArgs = Object.keys(portfolio).filter((f) => f.startsWith(rest));
109-
} else if (["cd"].includes(cmd)) {
110-
autocompleteArgs = _filesHere().filter((dir) => dir.startsWith(rest) && !_DIRS[term.cwd].includes(dir));
117+
const tabParts = term.currentLine.split(" ");
118+
const tabCmd = tabParts[0];
119+
const tabRest = tabParts.slice(1).join(" ");
120+
121+
// Check if we need to reset tab state (input changed)
122+
if (term.tabBase !== term.currentLine) {
123+
term.tabIndex = 0;
124+
term.tabOptions = [];
125+
term.tabBase = term.currentLine;
126+
127+
// Get completions based on context
128+
if (tabParts.length === 1) {
129+
// Completing command
130+
term.tabOptions = Object.keys(commands).filter(c => c.startsWith(tabCmd)).sort();
131+
} else if (["cat", "tail", "less", "head", "open", "mv", "cp", "chown", "chmod", "ls"].includes(tabCmd)) {
132+
term.tabOptions = _filesHere().filter(f => f.startsWith(tabRest)).sort();
133+
} else if (["whois", "finger", "groups"].includes(tabCmd)) {
134+
term.tabOptions = Object.keys(team).filter(f => f.startsWith(tabRest)).sort();
135+
} else if (["man", "woman", "tldr"].includes(tabCmd)) {
136+
term.tabOptions = Object.keys(portfolio).filter(f => f.startsWith(tabRest)).sort();
137+
} else if (["cd"].includes(tabCmd)) {
138+
term.tabOptions = _filesHere().filter(dir => dir.startsWith(tabRest) && !_DIRS[term.cwd].includes(dir)).sort();
139+
}
111140
}
112-
113-
// do the autocompleting
114-
if (autocompleteArgs && autocompleteArgs.length > 1) {
115-
const oldLine = term.currentLine;
116-
term.writeln(`\r\n${autocompleteArgs.join(" ")}`);
117-
term.prompt();
118-
term.setCurrentLine(oldLine);
119-
} else if (commands[cmd] && autocompleteArgs && autocompleteArgs.length > 0) {
120-
term.setCurrentLine(`${cmd} ${autocompleteArgs[0]}`);
121-
} else if (commands[cmd] && autocompleteArgs && autocompleteArgs.length == 0) {
122-
term.setCurrentLine(`${cmd} ${rest}`);
123-
} else if (autocompleteCmds && autocompleteCmds.length == 1) {
124-
term.setCurrentLine(`${autocompleteCmds[0]} `);
141+
142+
// Handle tab completion
143+
if (term.tabOptions.length === 0) {
144+
// No completions
145+
} else if (term.tabOptions.length === 1) {
146+
// Single match - complete it
147+
if (tabParts.length === 1) {
148+
// Check if it's already an exact match (like typing "ls" completely)
149+
if (tabCmd === term.tabOptions[0]) {
150+
// Exact match - just add a space
151+
term.setCurrentLine(`${term.tabOptions[0]} `);
152+
} else {
153+
// Partial match - complete it
154+
term.setCurrentLine(`${term.tabOptions[0]} `);
155+
}
156+
} else {
157+
term.setCurrentLine(`${tabCmd} ${term.tabOptions[0]}`);
158+
}
159+
term.tabBase = "";
160+
term.tabIndex = 0;
161+
term.tabOptions = [];
162+
} else {
163+
// Multiple matches
164+
if (term.tabIndex === 0) {
165+
// First tab - show options
166+
term.writeln(`\r\n${term.tabOptions.join(" ")}`);
167+
term.prompt();
168+
term.setCurrentLine(term.currentLine);
169+
term.tabIndex = 1;
170+
} else {
171+
// Cycling through options
172+
const option = term.tabOptions[(term.tabIndex - 1) % term.tabOptions.length];
173+
if (tabParts.length === 1) {
174+
term.setCurrentLine(option);
175+
} else {
176+
term.setCurrentLine(`${tabCmd} ${option}`);
177+
}
178+
term.tabIndex++;
179+
term.tabBase = term.currentLine; // Update base to current selection
180+
}
125181
}
126182
break;
127183
default: // Print all other characters
184+
// Reset tab state on any other key
185+
term.tabIndex = 0;
186+
term.tabOptions = [];
187+
term.tabBase = "";
188+
128189
const newLine = `${term.currentLine.slice(0, term.pos())}${e}${term.currentLine.slice(term.pos())}`;
129190
term.setCurrentLine(newLine, true);
130191
break;

0 commit comments

Comments
 (0)