Files
fidoca/src/autocomplete-input.js

363 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;