Add batch reads, @file completion, interruptible commands, tool sets

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.
This commit is contained in:
Muhammad Zakir Ramadhan
2026-09-03 01:37:48 +07:00
parent a5ace7a23f
commit 2fa6ee247b
36 changed files with 2541 additions and 215 deletions
+89
View File
@@ -0,0 +1,89 @@
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('@');
});