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
+164
View File
@@ -0,0 +1,164 @@
import { tool } from 'ai';
import { z } from 'zod';
const MAX_OUTPUT = 30_000;
const MAX_LOG = 40;
const cap = (s: string) =>
s.length <= MAX_OUTPUT ? s : `${s.slice(0, MAX_OUTPUT)}\n... [truncated ${s.length - MAX_OUTPUT} chars]`;
type GitResult = { ok: true; stdout: string } | { ok: false; message: string };
/**
* Runs git with an argument array, never a shell string.
*
* Arguments come from model output, so a shell would make `git log --author="; rm -rf /"`
* an injection. Spawning the binary directly with a fixed argv removes that entirely,
* which is also why these tools can be auto-approved.
*/
async function git(args: string[], cwd: string, timeout = 30_000): Promise<GitResult> {
let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>;
try {
proc = Bun.spawn(['git', ...args], { cwd, stdout: 'pipe', stderr: 'pipe', timeout });
} catch {
return { ok: false, message: 'git is not installed or not on PATH.' };
}
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
if (code !== 0) {
const message = stderr.trim() || stdout.trim() || `git exited ${code}`;
if (/not a git repository/i.test(message)) {
return { ok: false, message: `${cwd} is not a git repository.` };
}
return { ok: false, message };
}
return { ok: true, stdout };
}
const run = async (args: string[], empty: string): Promise<string> => {
const result = await git(args, process.cwd());
if (!result.ok) throw new Error(result.message);
return cap(result.stdout.trim() || empty);
};
const STATUS_LABEL: Record<string, string> = {
M: 'modified',
A: 'added',
D: 'deleted',
R: 'renamed',
C: 'copied',
U: 'conflicted',
'?': 'untracked',
'!': 'ignored',
};
export const gitStatusTool = tool({
description:
'Working tree status: current branch, and which files are staged, modified, or untracked. ' +
'Use it before proposing a commit, and to see what you have changed so far.',
inputSchema: z.object({}),
execute: async () => {
const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], process.cwd());
if (!branch.ok) throw new Error(branch.message);
const status = await git(['status', '--porcelain=v1'], process.cwd());
if (!status.ok) throw new Error(status.message);
const lines = status.stdout.split('\n').filter(Boolean);
if (lines.length === 0) return `On ${branch.stdout.trim()}, working tree clean.`;
// Porcelain v1 packs staged and unstaged state into two leading columns; naming
// them is the difference between the model understanding the state and guessing.
const described = lines.slice(0, 200).map((line) => {
const staged = line[0] ?? ' ';
const unstaged = line[1] ?? ' ';
const path = line.slice(3);
const parts: string[] = [];
if (staged !== ' ' && staged !== '?') parts.push(`staged ${STATUS_LABEL[staged] ?? staged}`);
if (unstaged !== ' ') parts.push(`${STATUS_LABEL[unstaged] ?? unstaged}`);
return `${path} (${parts.join(', ') || 'unknown'})`;
});
return cap([`On ${branch.stdout.trim()}, ${lines.length} changed:`, ...described].join('\n'));
},
});
export const gitDiffTool = tool({
description:
'Unified diff of uncommitted changes. Pass staged to see what is staged instead, or a path to narrow it. ' +
'Use it to review your own edits before claiming they are done.',
inputSchema: z.object({
staged: z.boolean().optional().describe('Diff the index against HEAD instead of the working tree'),
path: z.string().optional().describe('Limit the diff to one file or directory'),
}),
execute: async ({ staged, path }) => {
const args = ['diff', '--no-color'];
if (staged) args.push('--staged');
if (path) args.push('--', path);
return run(args, staged ? 'Nothing staged.' : 'No uncommitted changes.');
},
});
export const gitLogTool = tool({
description:
'Recent commits, newest first: short hash, date, author, subject. Pass a path to see only commits touching it. ' +
'Use it to find when something changed and who changed it.',
inputSchema: z.object({
limit: z.number().int().min(1).max(MAX_LOG).optional().describe(`Commits to return, default 15, max ${MAX_LOG}`),
path: z.string().optional().describe('Only commits touching this file or directory'),
}),
execute: async ({ limit, path }) => {
const args = ['log', `-n${limit ?? 15}`, '--date=short', '--pretty=format:%h %ad %an %s'];
if (path) args.push('--', path);
return run(args, 'No commits.');
},
});
export const gitShowTool = tool({
description:
'One commit in full: message, author, and its diff. Takes a hash, tag, or ref like HEAD~2. ' +
'Use it after git_log to see what a specific commit actually did.',
inputSchema: z.object({
ref: z.string().describe('Commit hash, tag, or ref'),
path: z.string().optional().describe('Limit the diff to one file'),
}),
execute: async ({ ref, path }) => {
const args = ['show', '--no-color', '--date=short', ref];
if (path) args.push('--', path);
return run(args, 'Nothing to show.');
},
});
export const gitBlameTool = tool({
description:
'Who last changed each line of a file, with the commit and date. Narrow with startLine and endLine. ' +
'Use it when a line looks wrong and its history explains why.',
inputSchema: z.object({
path: z.string().describe('File to blame'),
startLine: z.number().int().min(1).optional(),
endLine: z.number().int().min(1).optional(),
}),
execute: async ({ path, startLine, endLine }) => {
const args = ['blame', '--date=short', '-w'];
if (startLine) args.push('-L', `${startLine},${endLine ?? startLine + 40}`);
args.push('--', path);
return run(args, 'No blame output.');
},
});
export const gitTools = {
git_status: gitStatusTool,
git_diff: gitDiffTool,
git_log: gitLogTool,
git_show: gitShowTool,
git_blame: gitBlameTool,
};
/** Read-only, so none of these ever prompt for approval. */
export const GIT_TOOL_NAMES = Object.keys(gitTools);