This commit is contained in:
seb
2026-09-22 20:11:55 +02:00
parent b15ddfadc3
commit 96d2b80b6c
4 changed files with 223 additions and 6 deletions

117
src/autocomplete-input.js Normal file
View File

@@ -0,0 +1,117 @@
'use strict';
/**
* Slash-command autocomplete prompt.
*
* An `input`-style single-line prompt built on @inquirer/core. It behaves like
* a normal text input, but as soon as the buffer starts with "/" (and no
* arguments have been typed yet) it renders the matching slash commands as
* inline help below the prompt.
*
* ↑ / ↓ move the highlight between matches
* Tab complete the highlighted command into the buffer
* Enter submit the buffer (like a normal input)
*
* The filter term is frozen while the menu is open, so completing a command
* with Tab does not collapse the list — repeated ↑/↓ + Tab still works.
*/
import chalk from 'chalk';
import {
createPrompt,
useState,
useKeypress,
usePrefix,
isEnterKey,
isTabKey,
isUpKey,
isDownKey,
makeTheme,
} from '@inquirer/core';
import { filterCommands } from './slash-commands.js';
const MAX_SUGGESTIONS = 6;
/** Show the inline menu only while the first word is being typed (no space yet). */
function termFrom(line) {
if (typeof line !== 'string' || !line.startsWith('/')) return null;
if (line.includes(' ')) return null;
return line.toLowerCase();
}
/**
* Replace readline's actual edit buffer, not just the visible terminal line.
* `rl.clearLine()` only erases terminal output; using it before `rl.write()`
* leaves the old buffer in place and turns a completion such as `/h` into
* `/h/help` internally.
*/
function replaceBuffer(rl, nextValue) {
rl.write(null, { ctrl: true, name: 'a' });
rl.write(null, { ctrl: true, name: 'k' });
rl.write(nextValue);
}
export const autocompleteInput = createPrompt((config, done) => {
const theme = makeTheme({}, config.theme);
const [status, setStatus] = useState('idle');
const [value, setValue] = useState('');
const [term, setTerm] = useState(null); // frozen filter term while the menu is open
const [active, setActive] = useState(0); // highlighted suggestion index
const prefix = usePrefix({ status, theme });
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
const count = suggestions.length;
useKeypress((key, rl) => {
if (status !== 'idle') return;
if (isEnterKey(key)) {
const answer = value || rl.line;
setValue(answer);
setStatus('done');
done(answer);
return;
}
// Move the highlight within the open menu. Arrow keys do not change
// readline's text buffer, so no line clearing or rewriting is needed.
if (count > 0 && (isUpKey(key) || isDownKey(key))) {
const delta = isUpKey(key) ? -1 : 1;
setActive((active + delta + count) % count);
return;
}
// Complete the highlighted command into both the displayed value and
// readline's real edit buffer.
if (isTabKey(key)) {
if (count === 0) return;
const pick = suggestions[active % count];
replaceBuffer(rl, pick.name);
setValue(pick.name);
return; // keep `term` frozen so the menu stays open
}
// Ordinary editing: mirror the readline buffer and refresh the menu.
const line = rl.line;
setValue(line);
setTerm(termFrom(line));
setActive(0);
});
const message = theme.style.message(config.message);
const formattedValue = status === 'done' ? theme.style.answer(value) : value;
const lines = suggestions.map((c, i) => {
const marker = i === active % count ? chalk.cyan('❯') : ' ';
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(7))} ${chalk.dim(c.description)}`;
});
if (count > 1) {
lines.push(chalk.dim(' ↑/↓ choose · Tab complete'));
}
const header = [prefix, message, formattedValue].filter((v) => v !== undefined).join(' ');
return [header, lines.length ? lines.join('\n') : undefined];
});
export default autocompleteInput;