Add file browser, tests, and bin entrypoint
This commit is contained in:
28
README.md
28
README.md
@@ -27,6 +27,34 @@ fido /path/to/project # work on a specific directory
|
|||||||
|
|
||||||
Set `OPENROUTER_API_KEY` (and optionally `TAVILY_API_KEY`) in `~/.fido/.env`.
|
Set `OPENROUTER_API_KEY` (and optionally `TAVILY_API_KEY`) in `~/.fido/.env`.
|
||||||
|
|
||||||
|
### File mentions
|
||||||
|
|
||||||
|
While typing a message, press **`Ctrl+O`** to open an in-prompt file browser.
|
||||||
|
It is `.gitignore`-aware and hides dot-directories, just like `list_files`.
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| ----------- | -------------------------------------------------- |
|
||||||
|
| `↑` / `↓` | Move the highlight |
|
||||||
|
| `Enter` | Descend into a directory, or open the line selector|
|
||||||
|
| `Tab` | Mention the highlighted file (whole file) |
|
||||||
|
| `Backspace` | Go to the parent directory |
|
||||||
|
| `Esc` | Cancel back to the prompt |
|
||||||
|
|
||||||
|
In the line selector, `↑`/`↓` move the cursor, `v` starts/ends a range, `g`/`G`
|
||||||
|
jump to the first/last line, and `Enter` inserts the mention.
|
||||||
|
|
||||||
|
The mention is plain text spliced in at the cursor; the model reads the file
|
||||||
|
itself via `read_file` (it is a *reference*, not embedded contents):
|
||||||
|
|
||||||
|
| Selection | Inserted text |
|
||||||
|
| ----------- | ----------------------- |
|
||||||
|
| Whole file | `@src/fido.js` |
|
||||||
|
| Line range | `@src/fido.js:120-145` |
|
||||||
|
| Single line | `@src/fido.js:120` |
|
||||||
|
|
||||||
|
Paths are relative to the workspace root with forward slashes; line numbers are
|
||||||
|
1-based (the model maps them to `read_file`'s 0-indexed `seek`).
|
||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
61
bin/fido
Executable file
61
bin/fido
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# fido — launcher for the Fido coding assistant.
|
||||||
|
#
|
||||||
|
# Installed to $FIDO_HOME/bin/fido (default ~/.fido/bin/fido) by install.sh, but
|
||||||
|
# this file also works when run straight from a source checkout (bin/fido ->
|
||||||
|
# ../src/fido.js).
|
||||||
|
#
|
||||||
|
# All per-user state is kept under $FIDO_HOME:
|
||||||
|
# $FIDO_HOME/.env your API keys / model config
|
||||||
|
# $FIDO_HOME/state/<workspace>/undo.json the undo log for that workspace
|
||||||
|
# $FIDO_HOME/state/<workspace>/suspends/ /suspend snapshots for it
|
||||||
|
#
|
||||||
|
# Every workspace you run fido in gets its own state folder, so undo/suspend
|
||||||
|
# history never mixes between projects — and never litters the projects
|
||||||
|
# themselves (no more .fido-undo.json / .fido-suspends in your repos).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FIDO_HOME="${FIDO_HOME:-$HOME/.fido}"
|
||||||
|
|
||||||
|
# ── Load user config ──────────────────────────────────────────────────────────
|
||||||
|
# Exported vars win over any .env inside the workspace, because dotenv (used by
|
||||||
|
# src/config.js) never overwrites variables already present in the environment.
|
||||||
|
if [ -f "$FIDO_HOME/.env" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "$FIDO_HOME/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Resolve the app directory ─────────────────────────────────────────────────
|
||||||
|
if [ -f "$FIDO_HOME/app/src/fido.js" ]; then
|
||||||
|
APP_DIR="$FIDO_HOME/app"
|
||||||
|
else
|
||||||
|
# Running from a source checkout: this script lives in <root>/bin/.
|
||||||
|
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_DIR="$(cd "$self_dir/.." && pwd)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Per-workspace state under $FIDO_HOME/state/<slug>/ ────────────────────────
|
||||||
|
# The workspace is the first argument (like the app itself: `fido /path/to/proj`),
|
||||||
|
# falling back to the current directory.
|
||||||
|
workspace="${1:-$PWD}"
|
||||||
|
if [ -d "$workspace" ]; then
|
||||||
|
workspace="$(cd "$workspace" && pwd)"
|
||||||
|
else
|
||||||
|
workspace="$PWD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Human-readable, filesystem-safe slug + a short hash to guarantee uniqueness.
|
||||||
|
slug="$(printf '%s' "$workspace" | sed -e 's,^/,,' -e 's,/,_,g' -e 's,[^A-Za-z0-9._-],-,g')"
|
||||||
|
[ -n "$slug" ] || slug="root"
|
||||||
|
sum="$(printf '%s' "$workspace" | cksum | cut -d' ' -f1)"
|
||||||
|
state_dir="$FIDO_HOME/state/${slug}-${sum}"
|
||||||
|
mkdir -p "$state_dir/suspends"
|
||||||
|
|
||||||
|
export UNDO_FILE="$state_dir/undo.json"
|
||||||
|
export SUSPEND_DIR="$state_dir/suspends"
|
||||||
|
|
||||||
|
exec node "$APP_DIR/src/fido.js" "$@"
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
*
|
*
|
||||||
* The filter term is frozen while the menu is open, so completing a command
|
* 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.
|
* 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 chalk from 'chalk';
|
||||||
@@ -22,18 +28,30 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useKeypress,
|
useKeypress,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useRef,
|
||||||
usePrefix,
|
usePrefix,
|
||||||
isEnterKey,
|
isEnterKey,
|
||||||
isTabKey,
|
isTabKey,
|
||||||
isUpKey,
|
isUpKey,
|
||||||
isDownKey,
|
isDownKey,
|
||||||
|
isBackspaceKey,
|
||||||
makeTheme,
|
makeTheme,
|
||||||
} from '@inquirer/core';
|
} from '@inquirer/core';
|
||||||
|
|
||||||
import { COMMANDS, filterCommands } from './slash-commands.js';
|
import { COMMANDS, filterCommands } from './slash-commands.js';
|
||||||
|
import {
|
||||||
|
listEntries,
|
||||||
|
readLines,
|
||||||
|
formatMention,
|
||||||
|
normalizeRange,
|
||||||
|
windowStart,
|
||||||
|
parentOf,
|
||||||
|
} from './file-browser.js';
|
||||||
|
|
||||||
const MAX_SUGGESTIONS = 6;
|
const MAX_SUGGESTIONS = 6;
|
||||||
const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length));
|
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). */
|
/** Show the inline menu only while the first word is being typed (no space yet). */
|
||||||
function termFrom(line) {
|
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 [term, setTerm] = useState(termFrom(initial)); // frozen filter term while the menu is open
|
||||||
const [active, setActive] = useState(0); // highlighted suggestion index
|
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 prefix = usePrefix({ status, theme });
|
||||||
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
|
const suggestions = term === null ? [] : filterCommands(term).slice(0, MAX_SUGGESTIONS);
|
||||||
const count = suggestions.length;
|
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) => {
|
useKeypress((key, rl) => {
|
||||||
if (status !== 'idle') return;
|
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)) {
|
if (isEnterKey(key)) {
|
||||||
const answer = value || rl.line;
|
const answer = value || rl.line;
|
||||||
setValue(answer);
|
setValue(answer);
|
||||||
@@ -109,9 +273,80 @@ export const autocompleteInput = createPrompt((config, done) => {
|
|||||||
if (initial) rl.write(initial);
|
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 message = theme.style.message(config.message);
|
||||||
const formattedValue = status === 'done' ? theme.style.answer(value) : value;
|
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 lines = suggestions.map((c, i) => {
|
||||||
const marker = i === active % count ? chalk.cyan('❯') : ' ';
|
const marker = i === active % count ? chalk.cyan('❯') : ' ';
|
||||||
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${chalk.dim(c.description)}`;
|
return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${chalk.dim(c.description)}`;
|
||||||
|
|||||||
@@ -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.
|
- \`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_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\`.
|
- \`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
|
## Rules
|
||||||
- You may ONLY access files within the workspace root. Never use ".." or absolute paths.
|
- 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.
|
- 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 }];
|
let messages = [{ role: 'system', content: systemPrompt }];
|
||||||
|
|
||||||
console.log(chalk.dim(' Type your request below. Type / for commands, /paste for multiline.'));
|
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
|
// Restored into the prompt after a Ctrl+C cancels a running turn, so the
|
||||||
// message can be edited and resent instead of retyped.
|
// message can be edited and resent instead of retyped.
|
||||||
|
|||||||
141
src/file-browser.js
Normal file
141
src/file-browser.js
Normal 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;
|
||||||
|
}
|
||||||
148
test/compact.test.js
Normal file
148
test/compact.test.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for invalidateStaleReadResults in src/compact.js.
|
||||||
|
*
|
||||||
|
* Run with: node --test
|
||||||
|
* (or) node --test test/compact.test.js
|
||||||
|
*
|
||||||
|
* These are pure unit tests: they build synthetic transcript arrays and never
|
||||||
|
* call a model or touch the network. Importing compact.js also loads config.js
|
||||||
|
* (dotenv + ROOT_DIR) which is harmless and needs no API key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { invalidateStaleReadResults } from '../src/compact.js';
|
||||||
|
|
||||||
|
// A file body long enough that the replacement marker is strictly smaller.
|
||||||
|
const BODY = Array.from({ length: 40 }, (_, i) => `line ${i} of the file body`).join('\n');
|
||||||
|
|
||||||
|
// Assistant message declaring a tool call.
|
||||||
|
const call = (id, name, args) => ({
|
||||||
|
role: 'assistant',
|
||||||
|
tool_calls: [{ id, function: { name, arguments: JSON.stringify(args) } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// A read_file tool result carrying a whole file body.
|
||||||
|
const read = (id, filename, content = BODY) =>
|
||||||
|
({ role: 'tool', tool_call_id: id, content });
|
||||||
|
|
||||||
|
// A patch tool result. `applied` is the array of mutation tags the real
|
||||||
|
// applyOperations emits, e.g. ["PATCH in src/a.js"].
|
||||||
|
const patchResult = (id, applied) =>
|
||||||
|
({ role: 'tool', tool_call_id: id, content: JSON.stringify({ applied }) });
|
||||||
|
|
||||||
|
const isMarker = (content) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return typeof parsed.note === 'string' && parsed.note.includes('overwritten meanwhile');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('read before a later patch is invalidated', () => {
|
||||||
|
const messages = [
|
||||||
|
call('r1', 'read_file', { filename: 'src/a.js' }),
|
||||||
|
read('r1', 'src/a.js'),
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
|
||||||
|
patchResult('p1', ['PATCH in src/a.js']),
|
||||||
|
];
|
||||||
|
const n = invalidateStaleReadResults(messages);
|
||||||
|
assert.equal(n, 1);
|
||||||
|
assert.ok(isMarker(messages[1].content), 'read result replaced with marker');
|
||||||
|
assert.equal(JSON.parse(messages[1].content).filename, 'src/a.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read after the last patch is left intact (still matches disk)', () => {
|
||||||
|
const messages = [
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
|
||||||
|
patchResult('p1', ['PATCH in src/a.js']),
|
||||||
|
call('r1', 'read_file', { filename: 'src/a.js' }),
|
||||||
|
read('r1', 'src/a.js'),
|
||||||
|
];
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 0);
|
||||||
|
assert.equal(messages[3].content, BODY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a file no patch ever wrote is left intact', () => {
|
||||||
|
const messages = [
|
||||||
|
call('r1', 'read_file', { filename: 'src/untouched.js' }),
|
||||||
|
read('r1', 'src/untouched.js'),
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
|
||||||
|
patchResult('p1', ['PATCH in src/a.js']),
|
||||||
|
];
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 0);
|
||||||
|
assert.equal(messages[1].content, BODY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed patch (no applied entries) invalidates nothing', () => {
|
||||||
|
const messages = [
|
||||||
|
call('r1', 'read_file', { filename: 'src/a.js' }),
|
||||||
|
read('r1', 'src/a.js'),
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
|
||||||
|
patchResult('p1', []), // failure => nothing actually written
|
||||||
|
];
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 0);
|
||||||
|
assert.equal(messages[1].content, BODY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial failure: appliedSoFar still invalidates a written file', () => {
|
||||||
|
const messages = [
|
||||||
|
call('r1', 'read_file', { filename: 'src/a.js' }),
|
||||||
|
read('r1', 'src/a.js'),
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
|
||||||
|
{ role: 'tool', tool_call_id: 'p1', content: JSON.stringify({ appliedSoFar: ['CREATE/WRITE src/a.js'], error: 'boom' }) },
|
||||||
|
];
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 1);
|
||||||
|
assert.ok(isMarker(messages[1].content));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CREATE/WRITE tag is recognised', () => {
|
||||||
|
const messages = [
|
||||||
|
call('r1', 'read_file', { filename: 'src/new.js' }),
|
||||||
|
read('r1', 'src/new.js'),
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/new.js', search: null, replace: 'hi' }] }),
|
||||||
|
patchResult('p1', ['CREATE/WRITE src/new.js']),
|
||||||
|
];
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 1);
|
||||||
|
assert.ok(isMarker(messages[1].content));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('path canonicalization: ./src/a.js matches src/a.js', () => {
|
||||||
|
const messages = [
|
||||||
|
call('r1', 'read_file', { filename: './src/a.js' }),
|
||||||
|
read('r1', './src/a.js'),
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
|
||||||
|
patchResult('p1', ['PATCH in src/a.js']),
|
||||||
|
];
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 1);
|
||||||
|
assert.ok(isMarker(messages[1].content));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('size guard: a tiny read is never traded for a longer marker', () => {
|
||||||
|
const messages = [
|
||||||
|
call('r1', 'read_file', { filename: 'src/a.js' }),
|
||||||
|
read('r1', 'src/a.js', 'tiny'),
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
|
||||||
|
patchResult('p1', ['PATCH in src/a.js']),
|
||||||
|
];
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 0);
|
||||||
|
assert.equal(messages[1].content, 'tiny');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idempotent: a second pass changes nothing', () => {
|
||||||
|
const messages = [
|
||||||
|
call('r1', 'read_file', { filename: 'src/a.js' }),
|
||||||
|
read('r1', 'src/a.js'),
|
||||||
|
call('p1', 'patch', { operations: [{ file: 'src/a.js', search: 'x', replace: 'y' }] }),
|
||||||
|
patchResult('p1', ['PATCH in src/a.js']),
|
||||||
|
];
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 1);
|
||||||
|
const afterFirst = messages[1].content;
|
||||||
|
assert.equal(invalidateStaleReadResults(messages), 0);
|
||||||
|
assert.equal(messages[1].content, afterFirst);
|
||||||
|
});
|
||||||
|
|
||||||
146
test/file-browser.test.js
Normal file
146
test/file-browser.test.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the file-browser helpers in src/file-browser.js.
|
||||||
|
*
|
||||||
|
* Run with: node --test
|
||||||
|
* (or) node --test test/file-browser.test.js
|
||||||
|
*
|
||||||
|
* Pure unit tests except for `listEntries`/`readLines`, which read the real
|
||||||
|
* workspace (ROOT_DIR defaults to the process cwd, i.e. the repo root).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
listEntries,
|
||||||
|
readLines,
|
||||||
|
formatMention,
|
||||||
|
normalizeRange,
|
||||||
|
windowStart,
|
||||||
|
parentOf,
|
||||||
|
baseOf,
|
||||||
|
} from '../src/file-browser.js';
|
||||||
|
|
||||||
|
// ─── mention formatting ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('formatMention: whole file', () => {
|
||||||
|
assert.equal(formatMention('a/b.js'), '@a/b.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatMention: single line collapses start==end', () => {
|
||||||
|
assert.equal(formatMention('a/b.js', 10, 10), '@a/b.js:10');
|
||||||
|
assert.equal(formatMention('a/b.js', 10, null), '@a/b.js:10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatMention: line range', () => {
|
||||||
|
assert.equal(formatMention('a/b.js', 10, 20), '@a/b.js:10-20');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatMention: normalizes backslashes and trailing slashes', () => {
|
||||||
|
assert.equal(formatMention('a\\b.js', 1, 2), '@a/b.js:1-2');
|
||||||
|
assert.equal(formatMention('src/', 1, 2), '@src:1-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── range normalization ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('normalizeRange: anchor above cursor', () => {
|
||||||
|
assert.deepEqual(normalizeRange(2, 5), { start: 3, end: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeRange: anchor below cursor', () => {
|
||||||
|
assert.deepEqual(normalizeRange(5, 2), { start: 3, end: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeRange: single line', () => {
|
||||||
|
assert.deepEqual(normalizeRange(4, 4), { start: 5, end: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── windowing ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('windowStart: everything fits', () => {
|
||||||
|
assert.equal(windowStart(0, 5, 200), 0);
|
||||||
|
assert.equal(windowStart(4, 5, 200), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowStart: centers around the cursor', () => {
|
||||||
|
assert.equal(windowStart(100, 1000, 20), 90);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowStart: clamps at the top', () => {
|
||||||
|
assert.equal(windowStart(0, 1000, 20), 0);
|
||||||
|
assert.equal(windowStart(3, 1000, 20), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('windowStart: clamps at the bottom', () => {
|
||||||
|
assert.equal(windowStart(999, 1000, 20), 980);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── path helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('parentOf / baseOf', () => {
|
||||||
|
assert.equal(parentOf(''), '');
|
||||||
|
assert.equal(parentOf('src'), '');
|
||||||
|
assert.equal(parentOf('src/tools'), 'src');
|
||||||
|
assert.equal(baseOf('src/tools'), 'tools');
|
||||||
|
assert.equal(baseOf('README.md'), 'README.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── listing (real workspace) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('listEntries: dirs before files, dot-dirs and gitignored hidden', () => {
|
||||||
|
const entries = listEntries('');
|
||||||
|
const names = entries.map((e) => e.name);
|
||||||
|
|
||||||
|
// node_modules is hardcoded-ignored, .git is a dot-dir: both must be absent.
|
||||||
|
assert.ok(!names.includes('node_modules'), 'node_modules hidden');
|
||||||
|
assert.ok(!names.includes('.git'), '.git hidden');
|
||||||
|
|
||||||
|
// A root listing has no synthetic parent entry.
|
||||||
|
assert.ok(!names.includes('..'), 'no parent entry at root');
|
||||||
|
|
||||||
|
// src/ is a directory and README.md a file: directory must come first.
|
||||||
|
const srcIdx = names.indexOf('src');
|
||||||
|
const readmeIdx = names.indexOf('README.md');
|
||||||
|
assert.ok(srcIdx !== -1, 'src listed');
|
||||||
|
assert.ok(readmeIdx !== -1, 'README.md listed');
|
||||||
|
assert.ok(srcIdx < readmeIdx, 'directories sort before files');
|
||||||
|
|
||||||
|
const src = entries.find((e) => e.name === 'src');
|
||||||
|
assert.equal(src.type, 'd');
|
||||||
|
assert.equal(src.rel, 'src');
|
||||||
|
const readme = entries.find((e) => e.name === 'README.md');
|
||||||
|
assert.equal(readme.type, 'f');
|
||||||
|
assert.equal(readme.rel, 'README.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listEntries: nested listing prepends a parent entry', () => {
|
||||||
|
const entries = listEntries('src');
|
||||||
|
assert.equal(entries[0].name, '..');
|
||||||
|
assert.equal(entries[0].type, 'parent');
|
||||||
|
assert.equal(entries[0].rel, '');
|
||||||
|
assert.ok(entries.some((e) => e.name === 'fido.js' && e.type === 'f'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── reading (real workspace) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('readLines: reads a real file into lines', () => {
|
||||||
|
const { lines, binary } = readLines('README.md');
|
||||||
|
assert.equal(binary, false);
|
||||||
|
assert.ok(Array.isArray(lines));
|
||||||
|
assert.ok(lines.length > 0);
|
||||||
|
assert.ok(lines[0].includes('FIDO'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLines: rejects paths that escape the workspace', () => {
|
||||||
|
const res = readLines('../secrets.txt');
|
||||||
|
assert.equal(res.error, true);
|
||||||
|
assert.deepEqual(res.lines, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLines: missing file reports error, not a throw', () => {
|
||||||
|
const res = readLines('does/not/exist.js');
|
||||||
|
assert.equal(res.error, true);
|
||||||
|
assert.deepEqual(res.lines, []);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user