363 lines
15 KiB
JavaScript
363 lines
15 KiB
JavaScript
'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.
|
||
*
|
||
* It also hosts an in-prompt **file browser** (opened with Ctrl+O) with two
|
||
* modes: browse and line-select. The user picks a whole file (`@path`) or a
|
||
* line range (`@path:start-end`), and the reference is spliced into the draft
|
||
* at the caret. See `file-browser.js` for the pure helpers and PLAN.md for the
|
||
* design.
|
||
*/
|
||
|
||
import chalk from 'chalk';
|
||
import {
|
||
createPrompt,
|
||
useState,
|
||
useKeypress,
|
||
useEffect,
|
||
useRef,
|
||
usePrefix,
|
||
isEnterKey,
|
||
isTabKey,
|
||
isUpKey,
|
||
isDownKey,
|
||
isBackspaceKey,
|
||
makeTheme,
|
||
} from '@inquirer/core';
|
||
|
||
import { COMMANDS, filterCommands } from './slash-commands.js';
|
||
import {
|
||
listEntries,
|
||
readLines,
|
||
formatMention,
|
||
normalizeRange,
|
||
windowStart,
|
||
parentOf,
|
||
} from './file-browser.js';
|
||
|
||
const MAX_SUGGESTIONS = 6;
|
||
const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length));
|
||
const BROWSE_WINDOW = 12; // visible rows in the file browser
|
||
const LINE_WINDOW = 18; // visible rows in the line selector
|
||
|
||
/** 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 `/p` into
|
||
* `/p/paste` 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 initial = typeof config.default === 'string' ? config.default : '';
|
||
const [status, setStatus] = useState('idle');
|
||
const [value, setValue] = useState(initial);
|
||
const [term, setTerm] = useState(termFrom(initial)); // frozen filter term while the menu is open
|
||
const [active, setActive] = useState(0); // highlighted suggestion index
|
||
|
||
// ── File-browser state ──────────────────────────────────────────────
|
||
// mode: 'input' (normal editing) | 'browse' (file picker) | 'lines' (line selector)
|
||
const [mode, setMode] = useState('input');
|
||
const [cwd, setCwd] = useState(''); // browse dir, relative to ROOT_DIR ('' = root)
|
||
const [entries, setEntries] = useState([]); // listing for `cwd`
|
||
const [browseActive, setBrowseActive] = useState(0);
|
||
const [selFile, setSelFile] = useState(null); // file chosen in 'lines' mode
|
||
const [fileLines, setFileLines] = useState([]);
|
||
const [fileBinary, setFileBinary] = useState(false);
|
||
const [cursor, setCursor] = useState(0); // current line index in 'lines' mode
|
||
const [anchor, setAnchor] = useState(null); // range-selection anchor (line index) or null
|
||
const [notice, setNotice] = useState(''); // dim status/notice line
|
||
|
||
// The draft (and caret) captured when the browser opened, so a cancel can
|
||
// restore the untouched message and an insert knows where to splice.
|
||
const draftRef = useRef('');
|
||
const caretRef = useRef(0);
|
||
// Mirror `mode` into a ref so the SIGINT guard (installed once) sees it.
|
||
const modeRef = useRef('input');
|
||
modeRef.current = mode;
|
||
|
||
const prefix = usePrefix({ status, theme });
|
||
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
|
||
const count = suggestions.length;
|
||
|
||
// ── Browser helpers ─────────────────────────────────────────────────
|
||
const openDir = (rel) => {
|
||
setCwd(rel);
|
||
setEntries(listEntries(rel));
|
||
setBrowseActive(0);
|
||
setNotice('');
|
||
};
|
||
|
||
const openFile = (rel) => {
|
||
const { lines, binary, error } = readLines(rel);
|
||
if (error) {
|
||
setNotice(`could not read ${rel}`);
|
||
setEntries(listEntries(cwd)); // refresh in case it moved/vanished
|
||
return;
|
||
}
|
||
setSelFile(rel);
|
||
setFileLines(lines);
|
||
setFileBinary(binary);
|
||
setCursor(0);
|
||
setAnchor(null);
|
||
setNotice(binary ? 'binary file — whole-file mention only' : '');
|
||
setMode('lines');
|
||
};
|
||
|
||
const cancelToInput = (rl) => {
|
||
replaceBuffer(rl, draftRef.current);
|
||
const back = draftRef.current.length - caretRef.current;
|
||
for (let i = 0; i < back; i++) rl.write(null, { ctrl: true, name: 'b' });
|
||
setMode('input');
|
||
setNotice('');
|
||
};
|
||
|
||
// Splice `text` into the captured draft at the captured caret, leaving the
|
||
// caret just after the inserted text, then return to normal input mode.
|
||
const insertMention = (rl, text) => {
|
||
const draft = draftRef.current;
|
||
const pos = Math.min(caretRef.current, draft.length);
|
||
const before = draft.slice(0, pos);
|
||
const after = draft.slice(pos);
|
||
const pad = before.length && !/\s$/.test(before) ? ' ' : '';
|
||
const next = before + pad + text + after;
|
||
replaceBuffer(rl, next);
|
||
for (let i = 0; i < after.length; i++) rl.write(null, { ctrl: true, name: 'b' });
|
||
setValue(next);
|
||
setTerm(termFrom(next));
|
||
setActive(0);
|
||
setMode('input');
|
||
setNotice('');
|
||
};
|
||
|
||
useKeypress((key, rl) => {
|
||
if (status !== 'idle') return;
|
||
|
||
// ── Browse mode ─────────────────────────────────────────────────
|
||
if (mode === 'browse') {
|
||
const n = entries.length;
|
||
if (isUpKey(key)) {
|
||
if (n) setBrowseActive((browseActive - 1 + n) % n);
|
||
return;
|
||
}
|
||
if (isDownKey(key)) {
|
||
if (n) setBrowseActive((browseActive + 1) % n);
|
||
return;
|
||
}
|
||
if (isEnterKey(key)) {
|
||
const entry = entries[browseActive];
|
||
if (!entry) return;
|
||
if (entry.type === 'f') openFile(entry.rel);
|
||
else openDir(entry.rel); // dir or synthetic '..'
|
||
return;
|
||
}
|
||
if (isTabKey(key)) {
|
||
const entry = entries[browseActive];
|
||
if (entry && entry.type === 'f') insertMention(rl, formatMention(entry.rel));
|
||
return;
|
||
}
|
||
if (isBackspaceKey(key)) {
|
||
if (cwd) openDir(parentOf(cwd));
|
||
return;
|
||
}
|
||
if (key.name === 'escape') cancelToInput(rl);
|
||
return; // swallow every other key so the draft is untouched
|
||
}
|
||
|
||
// ── Line-select mode ────────────────────────────────────────────
|
||
if (mode === 'lines') {
|
||
const n = fileLines.length;
|
||
if (isUpKey(key)) {
|
||
setCursor(Math.max(0, cursor - 1));
|
||
return;
|
||
}
|
||
if (isDownKey(key)) {
|
||
setCursor(Math.max(0, Math.min(n - 1, cursor + 1)));
|
||
return;
|
||
}
|
||
if (!fileBinary && n > 0) {
|
||
if (key.name === 'v') { setAnchor(anchor === null ? cursor : null); return; }
|
||
if (key.name === 'g') { setCursor(0); return; }
|
||
if (key.name === 'G') { setCursor(n - 1); return; }
|
||
}
|
||
if (isEnterKey(key)) {
|
||
let text;
|
||
if (anchor !== null && n > 0) {
|
||
const { start, end } = normalizeRange(anchor, cursor);
|
||
text = formatMention(selFile, start, end);
|
||
} else {
|
||
text = formatMention(selFile);
|
||
}
|
||
insertMention(rl, text);
|
||
return;
|
||
}
|
||
if (key.name === 'escape' || isBackspaceKey(key)) {
|
||
setMode('browse');
|
||
setNotice('');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── Input mode (normal editing) ─────────────────────────────────
|
||
// Ctrl+O opens the file browser at the workspace root.
|
||
if (key.ctrl && key.name === 'o') {
|
||
draftRef.current = rl.line;
|
||
caretRef.current = rl.cursor;
|
||
openDir('');
|
||
setMode('browse');
|
||
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);
|
||
});
|
||
|
||
// Prefill the editable readline buffer (and mirror it into `value`) when an
|
||
// initial value is supplied — used to restore a message that a Ctrl+C
|
||
// cancelled back into the prompt so it can be edited and resent.
|
||
useEffect((rl) => {
|
||
if (initial) rl.write(initial);
|
||
}, []);
|
||
|
||
// While browsing, Ctrl+C should return to the prompt, not abort the whole
|
||
// prompt. @inquirer/core installs its own SIGINT listener that rejects the
|
||
// prompt; wrap it so it only fires when we're in normal input mode.
|
||
useEffect((rl) => {
|
||
const existing = rl.listeners('SIGINT');
|
||
rl.removeAllListeners('SIGINT');
|
||
const guarded = (...args) => {
|
||
if (modeRef.current === 'input') existing.forEach((fn) => fn.apply(rl, args));
|
||
};
|
||
rl.on('SIGINT', guarded);
|
||
return () => {
|
||
rl.removeAllListeners('SIGINT');
|
||
existing.forEach((fn) => rl.on('SIGINT', fn));
|
||
};
|
||
}, []);
|
||
|
||
const message = theme.style.message(config.message);
|
||
const formattedValue = status === 'done' ? theme.style.answer(value) : value;
|
||
|
||
// ── File-browser view ───────────────────────────────────────────────
|
||
if (mode === 'browse') {
|
||
const n = entries.length;
|
||
const start = windowStart(browseActive, n, BROWSE_WINDOW);
|
||
const end = Math.min(n, start + BROWSE_WINDOW);
|
||
const rows = [];
|
||
for (let i = start; i < end; i++) {
|
||
const e = entries[i];
|
||
const marker = i === browseActive ? chalk.cyan('❯') : ' ';
|
||
let label;
|
||
if (e.type === 'parent') label = chalk.dim('../');
|
||
else if (e.type === 'd') label = chalk.bold.cyan(`${e.name}/`);
|
||
else label = e.name;
|
||
rows.push(` ${marker} ${label}`);
|
||
}
|
||
const where = cwd ? `${cwd}/` : '(workspace root)';
|
||
const out = [
|
||
` ${chalk.bold.cyan('📁 File browser')} ${chalk.dim(where)}`,
|
||
rows.length ? rows.join('\n') : chalk.dim(' (empty directory)'),
|
||
];
|
||
if (notice) out.push(chalk.dim(` ${notice}`));
|
||
out.push(chalk.dim(' ↑/↓ move · Enter open · Tab mention · Backspace up · Esc cancel'));
|
||
return out.join('\n');
|
||
}
|
||
|
||
// ── Line-selector view ──────────────────────────────────────────────
|
||
if (mode === 'lines') {
|
||
const n = fileLines.length;
|
||
const out = [` ${chalk.bold.cyan(selFile)} ${chalk.dim('— select lines')}`];
|
||
if (fileBinary) {
|
||
out.push(chalk.dim(' (binary file — Enter mentions the whole file)'));
|
||
} else if (n === 0) {
|
||
out.push(chalk.dim(' (empty file — Enter mentions the whole file)'));
|
||
} else {
|
||
const start = windowStart(cursor, n, LINE_WINDOW);
|
||
const end = Math.min(n, start + LINE_WINDOW);
|
||
const width = String(n).length;
|
||
const lo = anchor === null ? null : Math.min(anchor, cursor);
|
||
const hi = anchor === null ? null : Math.max(anchor, cursor);
|
||
const rows = [];
|
||
for (let i = start; i < end; i++) {
|
||
const cur = i === cursor ? chalk.cyan('❯') : ' ';
|
||
const mark = lo !== null && i >= lo && i <= hi ? chalk.cyan('│') : ' ';
|
||
const num = chalk.dim(String(i + 1).padStart(width));
|
||
const text = fileLines[i].replace(/\t/g, ' ');
|
||
rows.push(` ${cur} ${num} ${mark} ${i === cursor ? chalk.white(text) : text}`);
|
||
}
|
||
out.push(rows.join('\n'));
|
||
}
|
||
if (notice) out.push(chalk.dim(` ${notice}`));
|
||
out.push(chalk.dim(' ↑/↓ move · v mark · g/G top/bottom · Enter insert · Esc back'));
|
||
return out.join('\n');
|
||
}
|
||
|
||
// ── Input view (unchanged) ──────────────────────────────────────────
|
||
const lines = suggestions.map((c, i) => {
|
||
const marker = i === active % count ? chalk.cyan('❯') : ' ';
|
||
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${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;
|