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