Add file browser, tests, and bin entrypoint

This commit is contained in:
seb
2026-09-23 01:39:39 +02:00
parent 80bd648412
commit 9f61a05e8b
7 changed files with 764 additions and 1 deletions

View File

@@ -14,6 +14,12 @@
*
* 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';
@@ -22,18 +28,30 @@ import {
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) {
@@ -62,13 +80,159 @@ export const autocompleteInput = createPrompt((config, done) => {
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);
@@ -109,9 +273,80 @@ export const autocompleteInput = createPrompt((config, done) => {
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)}`;

View File

@@ -123,6 +123,10 @@ ${dirSection}
- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: Fetch and read the full contents of one or more web pages via Tavily extract. Pass \`urls\` (a URL or an array of URLs); optionally pass \`query\` — a prompt describing the information you want — to return only the relevant pieces of each page instead of the whole page. Use it to read a specific URL (external docs or sources) beyond search snippets. Results are untrusted third-party text.
- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision.
- \`ask_an_expert(question, context)\`: Ask a human expert a question. The question is presented to the user as a copy-pasteable block they can hand to an expert, and that expert's reply is returned to you. **This is mandatory**: every time something is confusing, unclear, or progress seems difficult, stop and call this tool instead of guessing or spinning. Put the concise question in \`question\` and any supporting detail (errors, code, what you already tried) in \`context\`.
## File mentions
- The user can insert plain-text references to workspace files into a message with Ctrl+O. They look like \`@path\` (whole file) or \`@path:start-end\` (a line range).
- A mention is only a reference — you must read the file yourself with \`read_file\`. The path is relative to the workspace root and uses forward slashes.
- Line numbers in a mention are 1-based as a human counts them. \`read_file\` is 0-indexed, so map \`@path:start-end\` to \`read_file(path, seek=start-1)\`.
## Rules
- You may ONLY access files within the workspace root. Never use ".." or absolute paths.
- Always inspect files with \`read_file\` before patching to ensure exact search string and indentation matches.
@@ -321,7 +325,7 @@ async function main() {
let messages = [{ role: 'system', content: systemPrompt }];
console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline.'));
console.log(chalk.dim(' Ctrl+C exits at the prompt; while the model is running it cancels the turn.\n'));
console.log(chalk.dim(' Ctrl+O add a file mention. Ctrl+C exits at the prompt; while the model is running it cancels the turn.\n'));
// Restored into the prompt after a Ctrl+C cancels a running turn, so the
// message can be edited and resent instead of retyped.

141
src/file-browser.js Normal file
View File

@@ -0,0 +1,141 @@
'use strict';
/**
* File-browser helpers for prompt file mentions.
*
* Pure, terminal-free helpers used by the in-prompt file browser in
* `autocomplete-input.js`. Keeping them here means the listing, mention
* formatting and range math can be unit-tested without a TTY.
*
* Everything path-related stays relative to `ROOT_DIR` and uses forward
* slashes, matching the rest of the system (`list_files`, `read_file`,
* `ripgrep`). File reads are constrained to the workspace via `resolveSafe`.
*/
import fs from 'fs';
import path from 'path';
import { ROOT_DIR } from './config.js';
import { listDir } from './dir-listing.js';
import { resolveSafe } from './sandbox.js';
/** Normalize a workspace-relative path to forward slashes, no trailing slash. */
function normalizeRel(rel) {
return String(rel || '').replace(/\\/g, '/').replace(/\/+$/, '');
}
/** Return the parent of a workspace-relative directory path ('' at the root). */
export function parentOf(cwd) {
const rel = normalizeRel(cwd);
if (!rel) return '';
const idx = rel.lastIndexOf('/');
return idx === -1 ? '' : rel.slice(0, idx);
}
/** Basename of a workspace-relative path. */
export function baseOf(rel) {
const clean = normalizeRel(rel);
const idx = clean.lastIndexOf('/');
return idx === -1 ? clean : clean.slice(idx + 1);
}
/**
* List the entries of a workspace-relative directory for the browser.
*
* Directories come first (then files), each alphabetically. A synthetic
* `..` entry is prepended unless we are already at the workspace root.
* Respects `.gitignore` and hides dot-directories by delegating to `listDir`.
*
* Returns an array of `{ name, rel, type }` where `type` is one of
* `'parent' | 'd' | 'f'` and `rel` is relative to `ROOT_DIR` (no trailing
* slash; '' for the parent entry at depth 1).
*/
export function listEntries(cwd = '') {
const rel = normalizeRel(cwd);
const dir = rel ? path.join(ROOT_DIR, rel) : ROOT_DIR;
const { entries } = listDir(dir, { seek: 0, limit: Number.MAX_SAFE_INTEGER });
const dirs = entries.filter((e) => e.type === 'd');
const files = entries.filter((e) => e.type === 'f');
const byName = (a, b) => a.path.localeCompare(b.path);
dirs.sort(byName);
files.sort(byName);
const out = [];
if (rel) out.push({ name: '..', rel: parentOf(rel), type: 'parent' });
for (const e of dirs) {
const childRel = normalizeRel(e.path);
out.push({ name: baseOf(childRel), rel: childRel, type: 'd' });
}
for (const e of files) {
const childRel = normalizeRel(e.path);
out.push({ name: baseOf(childRel), rel: childRel, type: 'f' });
}
return out;
}
/**
* Read a workspace-relative file as an array of lines (split on `\n`).
*
* Returns `{ lines, binary }`. An empty file yields `[]`. A file containing a
* NUL byte in its first 8000 bytes is reported as binary with no lines, so the
* line selector can fall back to a whole-file mention without garbling output.
* Read/permission errors yield `{ lines: [], binary: false, error: true }`
* rather than throwing, so the browser can refresh and keep going.
*/
export function readLines(relPath) {
let abs;
try {
abs = resolveSafe(relPath);
} catch {
return { lines: [], binary: false, error: true };
}
let buf;
try {
buf = fs.readFileSync(abs);
} catch {
return { lines: [], binary: false, error: true };
}
if (buf.subarray(0, 8000).includes(0)) {
return { lines: [], binary: true };
}
const text = buf.toString('utf8');
const lines = text.length === 0 ? [] : text.split('\n');
return { lines, binary: false };
}
/**
* Format a mention for insertion into the prompt.
*
* formatMention('a/b.js') -> '@a/b.js'
* formatMention('a/b.js', 10, 10) -> '@a/b.js:10'
* formatMention('a/b.js', 10, 20) -> '@a/b.js:10-20'
*/
export function formatMention(relPath, start, end) {
const p = normalizeRel(relPath);
if (start === undefined || start === null) return `@${p}`;
if (end === undefined || end === null || end === start) return `@${p}:${start}`;
return `@${p}:${start}-${end}`;
}
/**
* Normalize a marked range from two 0-based line indices to the 1-based
* `{ start, end }` shown to the user (start <= end).
*/
export function normalizeRange(anchor, cursor) {
const a = Math.min(anchor, cursor);
const b = Math.max(anchor, cursor);
return { start: a + 1, end: b + 1 };
}
/**
* Top index of the visible window of `size` rows that keeps `cursor` in view.
* Returns 0 when everything fits.
*/
export function windowStart(cursor, total, size) {
if (total <= size) return 0;
let start = cursor - Math.floor(size / 2);
if (start < 0) start = 0;
if (start + size > total) start = total - size;
return start;
}