From 9f61a05e8bd903cee65c7fe29e3afe15df61cfcd Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 23 Sep 2026 01:39:39 +0200 Subject: [PATCH] Add file browser, tests, and bin entrypoint --- README.md | 28 +++++ bin/fido | 61 ++++++++++ src/autocomplete-input.js | 235 ++++++++++++++++++++++++++++++++++++++ src/fido.js | 6 +- src/file-browser.js | 141 +++++++++++++++++++++++ test/compact.test.js | 148 ++++++++++++++++++++++++ test/file-browser.test.js | 146 +++++++++++++++++++++++ 7 files changed, 764 insertions(+), 1 deletion(-) create mode 100755 bin/fido create mode 100644 src/file-browser.js create mode 100644 test/compact.test.js create mode 100644 test/file-browser.test.js diff --git a/README.md b/README.md index 09c0fa0..4406ba3 100644 --- a/README.md +++ b/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`. +### 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 ``` diff --git a/bin/fido b/bin/fido new file mode 100755 index 0000000..2155313 --- /dev/null +++ b/bin/fido @@ -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//undo.json the undo log for that workspace +# $FIDO_HOME/state//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 /bin/. + self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + APP_DIR="$(cd "$self_dir/.." && pwd)" +fi + +# ── Per-workspace state under $FIDO_HOME/state// ──────────────────────── +# 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" "$@" diff --git a/src/autocomplete-input.js b/src/autocomplete-input.js index 217539f..064bcc1 100644 --- a/src/autocomplete-input.js +++ b/src/autocomplete-input.js @@ -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)}`; diff --git a/src/fido.js b/src/fido.js index 815c6cb..c150e1d 100644 --- a/src/fido.js +++ b/src/fido.js @@ -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. diff --git a/src/file-browser.js b/src/file-browser.js new file mode 100644 index 0000000..701b188 --- /dev/null +++ b/src/file-browser.js @@ -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; +} diff --git a/test/compact.test.js b/test/compact.test.js new file mode 100644 index 0000000..e7d87b5 --- /dev/null +++ b/test/compact.test.js @@ -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); +}); + diff --git a/test/file-browser.test.js b/test/file-browser.test.js new file mode 100644 index 0000000..330b257 --- /dev/null +++ b/test/file-browser.test.js @@ -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, []); +});