Tools, six built-in to fourteen: - read_many_files: up to 20 paths read concurrently, each with its own window. An unreadable path is reported in its own block instead of throwing. - multi_edit: several edits to one file, validated in memory first so a late failure cannot leave the file half-written. - list_dir: ignore-aware depth-limited tree. - git_status/diff/log/show/blame: read-only, spawned with a fixed argv rather than a shell string, which is what makes them safe to auto-approve. toolSets gates them. core is always on; edit-plus and git are optional. A disabled set reaches neither the wire nor the system prompt, since a prompt naming an absent tool teaches calls that cannot succeed. Interface: - Reasoning streams to a collapsed panel, ctrl-r expands, dropped when the turn ends: it is progress, not the answer. - The tool in flight is named from tool-input-start, before its arguments finish streaming, and cleared on its result. - Prompts typed mid-turn queue and drain in order. esc clears the queue as well as aborting. - @ opens a path picker fed by the ignore-aware walker. Prefix matches rank above substring matches, so @src/ means "under src/". The walk runs on the first @, not at startup. ctrl-c kills the command in flight and keeps the turn. The call throws rather than returning, so the model cannot read a killed command as one that ran and failed on its own terms. The kill takes the whole process tree: killing cmd /c alone left the real command holding both pipes open, so the read never returned and the interrupt did nothing for 19 seconds. Two pruning fixes: - A tool result whose tool call was pruned is now dropped with it. Pruning counts messages, so the cut landed between an assistant tool-call and the tool message answering it, producing 400 "No tool call found for function call output with call_id ...". The reverse pairing is left alone: a call awaiting its result is what a suspended approval looks like. - ignore.ts called statFs without importing it, so walk() crashed on the first symlink. 482 tests, up from 404. Docs synced across README, ROADMAP, TODO, and all of docs/: tool sets, the new tools, ctrl-c semantics, the tool-start event, and the two hand-maintained tool-name lists recorded as a known weakness.
90 lines
2.8 KiB
TypeScript
90 lines
2.8 KiB
TypeScript
import { expect, test } from 'bun:test';
|
|
import { completePath, matchPaths, pathToken } from '../src/complete';
|
|
|
|
const paths = [
|
|
'README.md',
|
|
'src/app.ts',
|
|
'src/session.ts',
|
|
'src/ui/App.tsx',
|
|
'src/ui/Panels.tsx',
|
|
'test/session.test.ts',
|
|
'vendor/src/legacy.ts',
|
|
];
|
|
|
|
test('a bare @ opens the token with an empty query', () => {
|
|
expect(pathToken('@', 1)).toEqual({ start: 0, end: 1, query: '' });
|
|
});
|
|
|
|
test('the token is the text between @ and the cursor', () => {
|
|
expect(pathToken('look at @src/ses', 16)).toEqual({ start: 8, end: 16, query: 'src/ses' });
|
|
});
|
|
|
|
test('@ mid-word is not a completion, so an email is left alone', () => {
|
|
expect(pathToken('mail me@example.com', 19)).toBeUndefined();
|
|
expect(pathToken('user@host', 9)).toBeUndefined();
|
|
});
|
|
|
|
test('a space ends the token', () => {
|
|
expect(pathToken('@src/app.ts and then', 20)).toBeUndefined();
|
|
});
|
|
|
|
test('the cursor before the @ sees no token', () => {
|
|
expect(pathToken('@src', 0)).toBeUndefined();
|
|
});
|
|
|
|
test('the nearest @ wins when there are two', () => {
|
|
const token = pathToken('@first then @sec', 16);
|
|
expect(token?.query).toBe('sec');
|
|
expect(token?.start).toBe(12);
|
|
});
|
|
|
|
test('an empty query offers the shallowest paths first', () => {
|
|
expect(matchPaths(paths, '', 3)).toEqual(['README.md', 'src/app.ts', 'src/session.ts']);
|
|
});
|
|
|
|
test('a directory prefix narrows to what is under it', () => {
|
|
const hits = matchPaths(paths, 'src/ui/');
|
|
expect(hits).toEqual(['src/ui/App.tsx', 'src/ui/Panels.tsx']);
|
|
});
|
|
|
|
test('prefix matches rank above substring matches', () => {
|
|
const hits = matchPaths(paths, 'src/');
|
|
expect(hits[0]).toBe('src/app.ts');
|
|
// vendor/src/legacy.ts contains "src/" but is not under src/, so it comes last.
|
|
expect(hits.at(-1)).toBe('vendor/src/legacy.ts');
|
|
expect(hits.indexOf('src/session.ts')).toBeLessThan(hits.indexOf('vendor/src/legacy.ts'));
|
|
});
|
|
|
|
test('matching ignores case', () => {
|
|
expect(matchPaths(paths, 'readme')).toEqual(['README.md']);
|
|
});
|
|
|
|
test('a query matching nothing yields nothing', () => {
|
|
expect(matchPaths(paths, 'zzz')).toEqual([]);
|
|
});
|
|
|
|
test('the limit is honoured', () => {
|
|
expect(matchPaths(paths, 's', 2)).toHaveLength(2);
|
|
});
|
|
|
|
test('completion replaces the token with a plain relative path', () => {
|
|
const value = 'look at @src/ses';
|
|
const token = pathToken(value, value.length)!;
|
|
expect(completePath(value, token, 'src/session.ts')).toEqual({
|
|
value: 'look at src/session.ts ',
|
|
cursor: 23,
|
|
});
|
|
});
|
|
|
|
test('completion keeps whatever followed the cursor', () => {
|
|
const value = '@src/ses and explain';
|
|
const token = pathToken(value, 8)!;
|
|
const { value: next } = completePath(value, token, 'src/session.ts');
|
|
expect(next).toBe('src/session.ts and explain');
|
|
});
|
|
|
|
test('the inserted path carries no @', () => {
|
|
const token = pathToken('@READ', 5)!;
|
|
expect(completePath('@READ', token, 'README.md').value).not.toContain('@');
|
|
});
|