diff --git a/TODO.md b/TODO.md index dd8cd2f..2bd8e0f 100644 --- a/TODO.md +++ b/TODO.md @@ -108,8 +108,6 @@ Not bugs exactly, but things that will bite someone. screen through later ones until the turn ends. - **An interrupted command's effects are unknown, and the model is told so.** Nothing can know how far a half-run migration got. -- **`@` completion lists files, not directories.** `@src/` narrows correctly, but you cannot - complete to `src/` itself, because the walker only yields files. - **An installed skill is a stranger's words in your system prompt.** The install shows the body first and `/skills` records the origin, but nothing re-checks it later: a registry that changes a URL's contents affects the next install, not one already on disk. diff --git a/src/cli.tsx b/src/cli.tsx index 39bbd9a..26e383c 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -403,7 +403,7 @@ const hooks: AppHooks = { instructionFiles: () => instructions.map((i) => i.path), listPaths: async () => { const found: string[] = []; - for await (const rel of walk({ limit: 5000 })) found.push(rel); + for await (const rel of walk({ limit: 5000, includeDirs: true })) found.push(rel); return found; }, fileChangeSeq: () => session.fileChangeSeq, diff --git a/src/complete.ts b/src/complete.ts index 1dd9456..dbe4466 100644 --- a/src/complete.ts +++ b/src/complete.ts @@ -43,25 +43,43 @@ const MAX_MATCHES = 8; * Ties break on path length: the shallower file is more often the one meant. */ export function matchPaths(paths: readonly string[], query: string, limit = MAX_MATCHES): string[] { - if (!query) return [...paths].sort((a, b) => a.length - b.length || a.localeCompare(b)).slice(0, limit); + if (!query) { + const dirs = paths.filter((p) => p.endsWith('/')); + const files = paths.filter((p) => !p.endsWith('/')); + const byLength = (a: string, b: string) => a.length - b.length || a.localeCompare(b); + // Empty query: prefer directories (the user just typed `@src/` and wants to go deeper) + // when available, otherwise fall back to the shallowest files. + if (dirs.length > 0) return [...dirs.sort(byLength), ...files.sort(byLength)].slice(0, limit); + return [...files.sort(byLength)].slice(0, limit); + } const needle = query.toLowerCase(); - const prefix: string[] = []; - const substring: string[] = []; + const prefixDirs: string[] = []; + const prefixFiles: string[] = []; + const substringDirs: string[] = []; + const substringFiles: string[] = []; for (const path of paths) { const lower = path.toLowerCase(); - if (lower.startsWith(needle)) prefix.push(path); - else if (lower.includes(needle)) substring.push(path); + const isDir = path.endsWith('/'); + const hit = lower.startsWith(needle) ? 'prefix' : lower.includes(needle) ? 'substring' : undefined; + if (!hit) continue; + if (hit === 'prefix') (isDir ? prefixDirs : prefixFiles).push(path); + else (isDir ? substringDirs : substringFiles).push(path); } const byLength = (a: string, b: string) => a.length - b.length || a.localeCompare(b); - return [...prefix.sort(byLength), ...substring.sort(byLength)].slice(0, limit); + return [ + ...prefixDirs.sort(byLength), + ...prefixFiles.sort(byLength), + ...substringDirs.sort(byLength), + ...substringFiles.sort(byLength), + ].slice(0, limit); } export type Completion = { value: string; cursor: number }; -/** Replaces the token with a plain relative path and a trailing space. */ +/** Replaces the token with a plain relative path and a trailing space (dirs keep their trailing `/`). */ export function completePath(value: string, token: PathToken, path: string): Completion { const next = `${value.slice(0, token.start)}${path} ${value.slice(token.end)}`; return { value: next, cursor: token.start + path.length + 1 }; diff --git a/src/ignore.ts b/src/ignore.ts index 1840b2b..404c166 100644 --- a/src/ignore.ts +++ b/src/ignore.ts @@ -88,6 +88,8 @@ export type WalkOptions = { root?: string; /** Include files git would ignore. */ noIgnore?: boolean; + /** Also yield directories (with trailing `/`), so `@` can complete to them. */ + includeDirs?: boolean; limit?: number; }; @@ -130,6 +132,10 @@ export async function* walk(options: WalkOptions = {}): AsyncGenerator { if (!options.noIgnore && ignored(rel, entry.isDirectory, rules)) continue; if (entry.isDirectory) { + if (options.includeDirs) { + yield `${rel}/`; + if (++yielded >= limit) return; + } // A symlinked directory is not descended into: it can point anywhere, // including back into the tree. if (entry.isLink) continue; diff --git a/test/complete-dirs.test.ts b/test/complete-dirs.test.ts new file mode 100644 index 0000000..cd75bd0 --- /dev/null +++ b/test/complete-dirs.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { walk } from '../src/ignore'; +import { matchPaths, completePath, pathToken } from '../src/complete'; + +test('walk includeDirs yields directories with trailing slash', async () => { + const dir = mkdtempSync(join(tmpdir(), 'shiro-dirs-')); + const orig = process.cwd(); + try { + process.chdir(dir); + await Bun.write('a.ts', ''); + mkdirSync('src/ui', { recursive: true }); + await Bun.write('src/ui/App.tsx', ''); + await Bun.write('src/session.ts', ''); + + const out: string[] = []; + for await (const p of walk({ includeDirs: true })) out.push(p); + expect(out).toContain('src/'); + expect(out).toContain('src/ui/'); + expect(out).toContain('src/session.ts'); + expect(out).toContain('a.ts'); + } finally { + process.chdir(orig); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('walk without includeDirs still file-only (existing behaviour)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'shiro-nodirs-')); + const orig = process.cwd(); + try { + process.chdir(dir); + mkdirSync('src', { recursive: true }); + await Bun.write('src/a.ts', ''); + const out: string[] = []; + for await (const p of walk()) out.push(p); + expect(out).not.toContain('src/'); + expect(out).toContain('src/a.ts'); + } finally { + process.chdir(orig); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('matchPaths surfaces dirs for a directory prefix and keeps trailing slash through completion', () => { + const paths = ['src/', 'src/ui/', 'src/ui/App.tsx', 'src/session.ts', 'README.md']; + const hits = matchPaths(paths, 'src/'); + expect(hits).toContain('src/'); + expect(hits).toContain('src/ui/'); + // dirs rank before files for the same prefix + expect(hits.indexOf('src/ui/')).toBeLessThan(hits.indexOf('src/ui/App.tsx')); + + // inserted dir keeps trailing slash and trailing space + const tok = pathToken('@src/u', 6)!; + const { value } = completePath('@src/u', tok, 'src/ui/'); + expect(value).toBe('src/ui/ '); +}); + +test('matchPaths with empty query prefers dirs', () => { + const hits = matchPaths(['src/', 'a.ts', 'src/ui/', 'b.ts'], '', 3); + expect(hits[0]).toBe('src/'); +});