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:
@@ -28,9 +28,15 @@ export type AgentVariant = {
|
||||
|
||||
const READ_ONLY = [
|
||||
'read_file',
|
||||
'read_many_files',
|
||||
'glob',
|
||||
'grep',
|
||||
'list_dir',
|
||||
'git_status',
|
||||
'git_diff',
|
||||
'git_log',
|
||||
'git_show',
|
||||
'git_blame',
|
||||
'task',
|
||||
'todo_write',
|
||||
'remember',
|
||||
|
||||
+16
-2
@@ -7,6 +7,7 @@ import { configPath, loadConfig, missingKeyMessage, resolveModel, writeConfigFil
|
||||
import type { FallbackEvent } from './fallback';
|
||||
import { readStdin, runHeadless } from './headless';
|
||||
import { INIT_PROMPT, loadInstructions } from './instructions';
|
||||
import { walk } from './ignore';
|
||||
import { connectMcp } from './mcp';
|
||||
import { Memory, KIND_LABEL } from './memory';
|
||||
import { costOf } from './pricing';
|
||||
@@ -55,6 +56,7 @@ first run: start shiro with no key and it opens provider setup, or use /provider
|
||||
config: ${configPath()}
|
||||
{ "provider": "openai", "model": "gpt-5", "apiKey": "...",
|
||||
"agent": "default", "thinking": "medium", "plugins": ["guard", "time"],
|
||||
"toolSets": ["edit-plus", "git"],
|
||||
"mcpServers": { "fs": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] } } }
|
||||
|
||||
env: SHIRO_PROVIDER SHIRO_MODEL SHIRO_BASE_URL SHIRO_API_KEY
|
||||
@@ -213,6 +215,7 @@ const session = new Session({
|
||||
skills,
|
||||
plugins,
|
||||
agent: agentVariant,
|
||||
...(cfg.toolSets ? { toolSets: cfg.toolSets } : {}),
|
||||
// Headless has no one to answer, so the tool is withheld rather than left to hang.
|
||||
...(headless ? {} : { ask: askBridge.ask }),
|
||||
...(memory ? { memory } : {}),
|
||||
@@ -253,7 +256,9 @@ if (printArg !== undefined) {
|
||||
await shutdown(1);
|
||||
}
|
||||
if (!yolo) {
|
||||
process.stderr.write('shiro: headless denies write_file, edit_file, bash and mcp tools unless --yolo is passed\n');
|
||||
process.stderr.write(
|
||||
'shiro: headless denies write_file, edit_file, multi_edit, bash and mcp tools unless --yolo is passed\n',
|
||||
);
|
||||
}
|
||||
const code = await runHeadless({ session, prompt, format: has('--json') ? 'json' : 'text' });
|
||||
await shutdown(code);
|
||||
@@ -270,6 +275,11 @@ const hooks: AppHooks = {
|
||||
sessionId: record.id,
|
||||
config: () => cfg,
|
||||
instructionFiles: () => instructions.map((i) => i.path),
|
||||
listPaths: async () => {
|
||||
const found: string[] = [];
|
||||
for await (const rel of walk({ limit: 5000 })) found.push(rel);
|
||||
return found;
|
||||
},
|
||||
initPrompt: INIT_PROMPT,
|
||||
history: promptHistory,
|
||||
recordPrompt: (text) => void store.appendHistory(text),
|
||||
@@ -392,12 +402,15 @@ const header = [
|
||||
memory && memory.all().length > 0 ? `memory: ${memory.all().length} notes about this project` : undefined,
|
||||
mcp && Object.keys(mcp.tools).length > 0 ? `mcp: ${Object.keys(mcp.tools).length} tools` : undefined,
|
||||
...(mcp?.errors ?? []).map((e) => `mcp ${e.server} failed: ${e.message}`),
|
||||
yolo ? 'approvals: OFF (--yolo)' : 'approvals: on for write_file, edit_file, bash, mcp__*',
|
||||
yolo ? 'approvals: OFF (--yolo)' : 'approvals: on for write_file, edit_file, multi_edit, bash, mcp__*',
|
||||
cfg.toolSets ? `tool sets: core, ${cfg.toolSets.join(', ')}` : undefined,
|
||||
'/help for commands',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
// ctrl-c has to reach the App: with a command running it kills that command and
|
||||
// keeps the turn. Ink's own handler would exit the process before we saw the key.
|
||||
const app = render(
|
||||
<App
|
||||
session={session}
|
||||
@@ -409,6 +422,7 @@ const app = render(
|
||||
subagents={subagents}
|
||||
needsProvider={needsProvider}
|
||||
/>,
|
||||
{ exitOnCtrlC: false },
|
||||
);
|
||||
await app.waitUntilExit();
|
||||
await shutdown(0);
|
||||
|
||||
+8
-3
@@ -62,9 +62,14 @@ const usage = (c: CommandSpec) => `/${c.name}${c.arg ? ` ${c.arg}` : ''}`;
|
||||
export const HELP = [
|
||||
...COMMANDS.map((c) => `${usage(c).padEnd(18)} ${c.summary}`),
|
||||
'',
|
||||
'esc interrupt the running turn',
|
||||
'tab complete the highlighted command',
|
||||
'up / down recall earlier prompts',
|
||||
'esc interrupt the running turn and clear the queue',
|
||||
'ctrl-c kill the running command, keeping the turn',
|
||||
'ctrl-r expand or collapse the reasoning panel',
|
||||
'tab complete the highlighted command or file path',
|
||||
'up / down recall earlier prompts, or move in an open menu',
|
||||
'@ complete a workspace path',
|
||||
'',
|
||||
'typing during a turn queues the prompt; queued prompts run in order afterwards',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* `@path` completion, kept pure so the ranking and the insertion can be tested
|
||||
* without a terminal.
|
||||
*/
|
||||
|
||||
export type PathToken = {
|
||||
/** Index of the `@`. */
|
||||
start: number;
|
||||
/** Index just past the token, always the cursor. */
|
||||
end: number;
|
||||
/** Text between the `@` and the cursor, possibly empty. */
|
||||
query: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `@`-token the cursor sits in, if any.
|
||||
*
|
||||
* The `@` has to start a word, or `user@host` and an email address would open a
|
||||
* file picker. A space ends the token, so `@src/a.ts and then` is not still
|
||||
* completing after the space.
|
||||
*/
|
||||
export function pathToken(value: string, cursor: number): PathToken | undefined {
|
||||
const before = value.slice(0, cursor);
|
||||
const at = before.lastIndexOf('@');
|
||||
if (at === -1) return undefined;
|
||||
|
||||
const prev = at === 0 ? undefined : before[at - 1];
|
||||
if (prev !== undefined && !/\s/.test(prev)) return undefined;
|
||||
|
||||
const query = before.slice(at + 1);
|
||||
if (/\s/.test(query)) return undefined;
|
||||
|
||||
return { start: at, end: cursor, query };
|
||||
}
|
||||
|
||||
const MAX_MATCHES = 8;
|
||||
|
||||
/**
|
||||
* Paths worth offering for a query.
|
||||
*
|
||||
* Prefix matches come first because `@src/` means "under src/", and a substring
|
||||
* match on some other directory would bury the thing the user is pointing at.
|
||||
* 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);
|
||||
|
||||
const needle = query.toLowerCase();
|
||||
const prefix: string[] = [];
|
||||
const substring: 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 byLength = (a: string, b: string) => a.length - b.length || a.localeCompare(b);
|
||||
return [...prefix.sort(byLength), ...substring.sort(byLength)].slice(0, limit);
|
||||
}
|
||||
|
||||
export type Completion = { value: string; cursor: number };
|
||||
|
||||
/** Replaces the token with a plain relative path and a trailing space. */
|
||||
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 };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { withFallback, type FallbackEvent } from './fallback';
|
||||
import type { McpServerConfig } from './mcp';
|
||||
import { isToolSetName, type ToolSetName } from './tools';
|
||||
|
||||
export type ProviderName = 'anthropic' | 'openai';
|
||||
|
||||
@@ -24,6 +25,8 @@ export type Config = {
|
||||
thinking?: string;
|
||||
/** Plugin names to enable; omit for the default set. */
|
||||
plugins?: string[];
|
||||
/** Optional tool sets to offer beyond `core`; omit for all of them. */
|
||||
toolSets?: ToolSetName[];
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
};
|
||||
|
||||
@@ -85,6 +88,7 @@ export async function loadConfig(): Promise<Config> {
|
||||
...(file.agent ? { agent: file.agent } : {}),
|
||||
...(file.thinking ? { thinking: file.thinking } : {}),
|
||||
...(Array.isArray(file.plugins) ? { plugins: file.plugins } : {}),
|
||||
...(Array.isArray(file.toolSets) ? { toolSets: file.toolSets.filter(isToolSetName) } : {}),
|
||||
...(file.mcpServers ? { mcpServers: file.mcpServers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
+23
-6
@@ -1,5 +1,5 @@
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { readdir as readdirFs } from 'node:fs/promises';
|
||||
import { readdir as readdirFs, stat as statFs } from 'node:fs/promises';
|
||||
|
||||
const ALWAYS_SKIP = ['.git', 'node_modules'];
|
||||
|
||||
@@ -109,10 +109,16 @@ export async function* walk(options: WalkOptions = {}): AsyncGenerator<string> {
|
||||
const { dir, rules } = queue.shift()!;
|
||||
let entries: Entry[];
|
||||
try {
|
||||
entries = (await readdirFs(dir, { withFileTypes: true })).map((d) => ({
|
||||
name: d.name,
|
||||
isDirectory: d.isDirectory(),
|
||||
}));
|
||||
entries = await Promise.all(
|
||||
(await readdirFs(dir, { withFileTypes: true })).map(async (d) => ({
|
||||
name: d.name,
|
||||
// readdir reports a symlinked directory as a non-directory, which made
|
||||
// junctions leak past `dir/` ignore rules and be yielded as files with a
|
||||
// nonsense size. One stat per link fixes the classification.
|
||||
isDirectory: d.isDirectory() || (d.isSymbolicLink() && (await isDirLink(join(dir, d.name)))),
|
||||
isLink: d.isSymbolicLink(),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -124,6 +130,9 @@ export async function* walk(options: WalkOptions = {}): AsyncGenerator<string> {
|
||||
if (!options.noIgnore && ignored(rel, entry.isDirectory, rules)) continue;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
// A symlinked directory is not descended into: it can point anywhere,
|
||||
// including back into the tree.
|
||||
if (entry.isLink) continue;
|
||||
const nested = options.noIgnore ? rules : [...rules, ...(await rulesIn(root, full))];
|
||||
queue.push({ dir: full, rules: nested });
|
||||
} else {
|
||||
@@ -134,7 +143,15 @@ export async function* walk(options: WalkOptions = {}): AsyncGenerator<string> {
|
||||
}
|
||||
}
|
||||
|
||||
type Entry = { name: string; isDirectory: boolean };
|
||||
async function isDirLink(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await statFs(path)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type Entry = { name: string; isDirectory: boolean; isLink: boolean };
|
||||
|
||||
/** Resolves a model-supplied path inside the workspace, rejecting escapes. */
|
||||
export function jail(p: string, root = process.cwd()): string {
|
||||
|
||||
+23
-1
@@ -1,4 +1,5 @@
|
||||
import { formatInstructions, type Instructions } from './instructions';
|
||||
import { GIT_TOOL_NAMES } from './tools-git';
|
||||
|
||||
export type PromptParts = {
|
||||
cwd: string;
|
||||
@@ -30,6 +31,10 @@ type ToolDoc = { name: string; line: string };
|
||||
*/
|
||||
const TOOL_DOCS: ToolDoc[] = [
|
||||
{ name: 'read_file', line: 'read before you edit. Never describe code you have not opened.' },
|
||||
{
|
||||
name: 'read_many_files',
|
||||
line: 'read several files in one round trip once you know which ones you need. An unreadable path is reported in place, not fatal.',
|
||||
},
|
||||
{
|
||||
name: 'glob',
|
||||
line: 'find files by pattern. Skips binaries and .gitignore; pass includeIgnored to look anyway.',
|
||||
@@ -42,7 +47,15 @@ const TOOL_DOCS: ToolDoc[] = [
|
||||
name: 'edit_file',
|
||||
line: 'oldString must match byte-for-byte including indentation, and be unique. Include surrounding lines to disambiguate. Prefer several small edits over one large rewrite.',
|
||||
},
|
||||
{
|
||||
name: 'multi_edit',
|
||||
line: 'several edits to one file, all or nothing. Use it instead of repeated edit_file calls on the same file: one approval, one write, and a failed match leaves the file untouched.',
|
||||
},
|
||||
{ name: 'write_file', line: 'new files and full rewrites only. Reach for edit_file on anything that exists.' },
|
||||
{
|
||||
name: 'list_dir',
|
||||
line: 'tree view of a directory, ignore-aware and depth-limited. Cheaper than guessing at glob patterns in an unfamiliar project.',
|
||||
},
|
||||
{
|
||||
name: 'bash',
|
||||
line: 'builds, tests, git, package managers. Output streams live. Long-running commands are fine; interactive ones are not.',
|
||||
@@ -72,8 +85,17 @@ function renderTools(available: readonly string[]): string {
|
||||
|
||||
const lines = known.map((d) => `- ${d.name}: ${d.line}`);
|
||||
|
||||
// The git set gets one shared line instead of five: they are all read-only, all
|
||||
// free, and the schema already says what each takes.
|
||||
const git = extra.filter((n) => GIT_TOOL_NAMES.includes(n));
|
||||
const mcp = extra.filter((n) => n.startsWith('mcp__'));
|
||||
const other = extra.filter((n) => !n.startsWith('mcp__'));
|
||||
const other = extra.filter((n) => !GIT_TOOL_NAMES.includes(n) && !n.startsWith('mcp__'));
|
||||
|
||||
if (git.length > 0) {
|
||||
lines.push(
|
||||
`- ${git.join(', ')}: read-only git, no approval needed. Use them instead of bash for history and diffs; they cannot mutate the repository.`,
|
||||
);
|
||||
}
|
||||
if (mcp.length > 0) {
|
||||
lines.push(
|
||||
`- ${mcp.join(', ')}: from MCP servers, named mcp__<server>__<tool>. Each needs approval; read its own description before calling.`,
|
||||
|
||||
+52
-2
@@ -1,6 +1,10 @@
|
||||
import { pruneMessages, type ModelMessage } from 'ai';
|
||||
|
||||
type Part = { type: string; providerOptions?: Record<string, Record<string, unknown>> };
|
||||
type Part = {
|
||||
type: string;
|
||||
toolCallId?: string;
|
||||
providerOptions?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** Parts the OpenAI responses API refuses to accept without their reasoning item. */
|
||||
const DEPENDENT = new Set(['text', 'tool-call']);
|
||||
@@ -79,8 +83,54 @@ export function dropOrphanedItems(before: ModelMessage[], after: ModelMessage[])
|
||||
|
||||
export type PruneOptions = Parameters<typeof pruneMessages>[0];
|
||||
|
||||
const ANSWER_PARTS = new Set(['tool-result', 'tool-error']);
|
||||
|
||||
const anyParts = (message: ModelMessage): Part[] =>
|
||||
Array.isArray(message.content) ? (message.content as Part[]) : [];
|
||||
|
||||
/**
|
||||
* Drops tool results whose tool call is gone.
|
||||
*
|
||||
* The OpenAI responses API rejects a `function_call_output` with no `function_call`
|
||||
* carrying the same call id: 400 "No tool call found for function call output with
|
||||
* call_id ...". Two things strand a result that way, and both happen on a long turn:
|
||||
* `pruneMessages({ toolCalls: 'before-last-3-messages' })` counts messages, so the
|
||||
* cut can land between an assistant tool-call and the tool message answering it, and
|
||||
* `dropOrphanedItems` removes a tool-call whose reasoning item did not survive while
|
||||
* the result sits in a separate message it never looks at.
|
||||
*
|
||||
* The reverse pairing is left alone on purpose: a call still awaiting its result is
|
||||
* exactly what a suspended approval looks like, and dropping it would break resume.
|
||||
*/
|
||||
export function dropOrphanedResults(messages: ModelMessage[]): ModelMessage[] {
|
||||
const calls = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of anyParts(message)) {
|
||||
if (part.type === 'tool-call' && part.toolCallId) calls.add(part.toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned: ModelMessage[] = [];
|
||||
for (const message of messages) {
|
||||
const parts = anyParts(message);
|
||||
if (parts.length === 0) {
|
||||
cleaned.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
const kept = parts.filter(
|
||||
(part) => !ANSWER_PARTS.has(part.type) || part.toolCallId === undefined || calls.has(part.toolCallId),
|
||||
);
|
||||
|
||||
if (kept.length === parts.length) cleaned.push(message);
|
||||
else if (kept.length > 0) cleaned.push({ ...message, content: kept } as ModelMessage);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/** pruneMessages, then repair the provider-item dependencies it breaks. */
|
||||
export function prunePreservingItems(options: PruneOptions): ModelMessage[] {
|
||||
const pruned = pruneMessages(options);
|
||||
return dropOrphanedItems(options.messages, pruned);
|
||||
return dropOrphanedResults(dropOrphanedItems(options.messages, pruned));
|
||||
}
|
||||
|
||||
+22
-3
@@ -16,7 +16,13 @@ import type { PluginHost } from './plugins';
|
||||
import { systemPrompt } from './prompt';
|
||||
import { prunePreservingItems } from './prune';
|
||||
import { createSkillTool, renderSkills, type Skill } from './skills';
|
||||
import { MUTATING_TOOLS, onBashOutput, tools as builtinTools } from './tools';
|
||||
import {
|
||||
MUTATING_TOOLS,
|
||||
disabledToolNames,
|
||||
onBashOutput,
|
||||
tools as builtinTools,
|
||||
type ToolSetName,
|
||||
} from './tools';
|
||||
|
||||
export type ApprovalRequest = {
|
||||
approvalId: string;
|
||||
@@ -30,6 +36,7 @@ export type ApprovalDecision = 'once' | 'always' | 'deny';
|
||||
export type AgentEvent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'reasoning'; text: string }
|
||||
| { type: 'tool-start'; id: string; name: string }
|
||||
| { type: 'tool-call'; id: string; name: string; input: unknown }
|
||||
| { type: 'tool-output'; id: string; chunk: string }
|
||||
| { type: 'tool-result'; id: string; name: string; output: unknown }
|
||||
@@ -48,6 +55,8 @@ export type SessionOptions = {
|
||||
maxSteps?: number;
|
||||
/** MCP and subagent tools merged on top of the built-ins. */
|
||||
extraTools?: ToolSet;
|
||||
/** Tool sets offered this session; omit for all of them. `core` is always on. */
|
||||
toolSets?: readonly ToolSetName[];
|
||||
/** Tool names that never prompt, e.g. the read-only subagent tool. */
|
||||
autoApprove?: readonly string[];
|
||||
/** Prune the history once the estimated token count crosses this. */
|
||||
@@ -121,9 +130,14 @@ export class Session {
|
||||
return this.variant;
|
||||
}
|
||||
|
||||
/** Tool names offered this turn; a read-only variant hides the rest. */
|
||||
/**
|
||||
* Tool names offered this turn. A read-only variant hides the mutating tools;
|
||||
* a disabled tool set is withheld from the wire and from the prompt, since a
|
||||
* prompt that names an absent tool teaches calls that cannot succeed.
|
||||
*/
|
||||
activeTools(): string[] {
|
||||
const all = Object.keys(this.tools);
|
||||
const withheld = new Set(disabledToolNames(this.opts.toolSets));
|
||||
const all = Object.keys(this.tools).filter((name) => !withheld.has(name));
|
||||
if (!this.variant.allowTools) return all;
|
||||
return all.filter((name) => this.variant.allowTools!.includes(name));
|
||||
}
|
||||
@@ -300,6 +314,11 @@ export class Session {
|
||||
case 'reasoning-delta':
|
||||
yield { type: 'reasoning', text: part.text };
|
||||
break;
|
||||
case 'tool-input-start':
|
||||
// Arrives before the arguments finish streaming, so the UI can name
|
||||
// the tool while the model is still writing its input.
|
||||
yield { type: 'tool-start', id: part.id, name: part.toolName };
|
||||
break;
|
||||
case 'tool-call':
|
||||
yield { type: 'tool-call', id: part.toolCallId, name: part.toolName, input: part.input };
|
||||
break;
|
||||
|
||||
@@ -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);
|
||||
+297
-27
@@ -1,7 +1,9 @@
|
||||
import { tool } from 'ai';
|
||||
import { resolve } from 'node:path';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { jail, posix, walk } from './ignore';
|
||||
import { GIT_TOOL_NAMES, gitTools } from './tools-git';
|
||||
|
||||
/** Max chars returned by any single tool. Beyond this the output is truncated. */
|
||||
const MAX_OUTPUT = 30_000;
|
||||
@@ -23,6 +25,20 @@ async function isBinary(abs: string): Promise<boolean> {
|
||||
return bytes.includes(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* One file, numbered. Shared by read_file and read_many_files so a batch read
|
||||
* cannot drift from a single read in numbering or in what it refuses.
|
||||
*/
|
||||
async function readNumbered(path: string, offset: number, limit: number): Promise<string> {
|
||||
const abs = jail(path);
|
||||
const file = Bun.file(abs);
|
||||
if (!(await file.exists())) throw new Error(`No such file: ${path}`);
|
||||
if (await isBinary(abs)) throw new Error(`${path} is a binary file, not text. Use bash if you need to inspect it.`);
|
||||
const lines = (await file.text()).split('\n');
|
||||
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
||||
return slice.map((l, i) => `${offset + i}: ${l}`).join('\n');
|
||||
}
|
||||
|
||||
export const readFileTool = tool({
|
||||
description: 'Read a UTF-8 text file. Returns contents with 1-based line numbers.',
|
||||
inputSchema: z.object({
|
||||
@@ -30,14 +46,42 @@ export const readFileTool = tool({
|
||||
offset: z.number().int().min(1).optional().describe('First line to return (1-based)'),
|
||||
limit: z.number().int().min(1).optional().describe('Max lines to return, default 2000'),
|
||||
}),
|
||||
execute: async ({ path, offset = 1, limit = 2000 }) => {
|
||||
const abs = jail(path);
|
||||
const file = Bun.file(abs);
|
||||
if (!(await file.exists())) throw new Error(`No such file: ${path}`);
|
||||
if (await isBinary(abs)) throw new Error(`${path} is a binary file, not text. Use bash if you need to inspect it.`);
|
||||
const lines = (await file.text()).split('\n');
|
||||
const slice = lines.slice(offset - 1, offset - 1 + limit);
|
||||
return cap(slice.map((l, i) => `${offset + i}: ${l}`).join('\n'));
|
||||
execute: async ({ path, offset = 1, limit = 2000 }) => cap(await readNumbered(path, offset, limit)),
|
||||
});
|
||||
|
||||
const MAX_BATCH_FILES = 20;
|
||||
|
||||
export const readManyFilesTool = tool({
|
||||
description:
|
||||
'Read several text files in one call. Use it when you already know which files you need — one round trip ' +
|
||||
'instead of one per file. Each file may set its own offset and limit. A path that cannot be read is reported ' +
|
||||
'in its own block and does not stop the others, so a wrong guess costs one line rather than the whole call.',
|
||||
inputSchema: z.object({
|
||||
files: z
|
||||
.array(
|
||||
z.object({
|
||||
path: z.string().describe('File path relative to the workspace root'),
|
||||
offset: z.number().int().min(1).optional().describe('First line to return (1-based)'),
|
||||
limit: z.number().int().min(1).optional().describe('Max lines to return, default 2000'),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(MAX_BATCH_FILES)
|
||||
.describe(`The files to read, at most ${MAX_BATCH_FILES}`),
|
||||
}),
|
||||
execute: async ({ files }) => {
|
||||
// The whole point is one round trip, so the reads run together rather than
|
||||
// in sequence. A rejection is reported in place, not thrown.
|
||||
const blocks = await Promise.all(
|
||||
files.map(async ({ path, offset = 1, limit = 2000 }) => {
|
||||
try {
|
||||
return `===== ${path} =====\n${await readNumbered(path, offset, limit)}`;
|
||||
} catch (e) {
|
||||
return `===== ${path} =====\n[unreadable: ${e instanceof Error ? e.message : String(e)}]`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return cap(blocks.join('\n\n'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -82,6 +126,61 @@ export const editFileTool = tool({
|
||||
},
|
||||
});
|
||||
|
||||
export const multiEditTool = tool({
|
||||
description:
|
||||
'Apply several exact-string edits to one file in a single call. Each edit sees the result of the previous one. ' +
|
||||
'All or nothing: if any oldString fails to match, or matches more than once without replaceAll, nothing is ' +
|
||||
'written. Prefer this over repeated edit_file calls on the same file — one approval, one write, no risk of ' +
|
||||
'leaving the file half-changed.',
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
edits: z
|
||||
.array(
|
||||
z.object({
|
||||
oldString: z.string().describe('Exact text to find, including whitespace and indentation'),
|
||||
newString: z.string().describe('Replacement text'),
|
||||
replaceAll: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe('Edits in the order they should be applied'),
|
||||
}),
|
||||
execute: async ({ path, edits }) => {
|
||||
const abs = jail(path);
|
||||
const file = Bun.file(abs);
|
||||
if (!(await file.exists())) throw new Error(`No such file: ${path}`);
|
||||
|
||||
const original = await file.text();
|
||||
let text = original;
|
||||
const applied: string[] = [];
|
||||
|
||||
// Every edit is validated and applied in memory first. A failure on edit three
|
||||
// must not leave the first two on disk, which is the whole point of this tool.
|
||||
for (const [i, edit] of edits.entries()) {
|
||||
const { oldString, newString, replaceAll = false } = edit;
|
||||
if (oldString === newString) throw new Error(`edit ${i + 1}: oldString and newString are identical`);
|
||||
|
||||
const count = text.split(oldString).length - 1;
|
||||
if (count === 0) {
|
||||
throw new Error(`edit ${i + 1}: oldString not found in ${path}. No edits were applied.`);
|
||||
}
|
||||
if (count > 1 && !replaceAll) {
|
||||
throw new Error(
|
||||
`edit ${i + 1}: oldString appears ${count} times in ${path}. Add surrounding context or set replaceAll. No edits were applied.`,
|
||||
);
|
||||
}
|
||||
|
||||
text = replaceAll ? text.split(oldString).join(newString) : text.replace(oldString, newString);
|
||||
applied.push(`${replaceAll ? count : 1}x`);
|
||||
}
|
||||
|
||||
if (text === original) throw new Error(`No change to ${path}: the edits cancel out.`);
|
||||
|
||||
await Bun.write(abs, text);
|
||||
return `Applied ${edits.length} edit(s) to ${path} (${applied.join(', ')})`;
|
||||
},
|
||||
});
|
||||
|
||||
export const globTool = tool({
|
||||
description:
|
||||
'Find files by glob pattern, e.g. "src/**/*.ts". Skips anything .gitignore excludes. Returns paths relative to the workspace root.',
|
||||
@@ -102,6 +201,63 @@ export const globTool = tool({
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_TREE_ENTRIES = 300;
|
||||
|
||||
export const listDirTool = tool({
|
||||
description:
|
||||
'Directory tree, honouring .gitignore. Use it first to orient yourself in an unfamiliar project instead of ' +
|
||||
'guessing at glob patterns. Directories end with /, files show their size.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().optional().describe('Directory to list, relative to the workspace root. Default the root.'),
|
||||
depth: z.number().int().min(1).max(6).optional().describe('How many levels deep, default 2'),
|
||||
includeIgnored: z.boolean().optional().describe('Also show files git ignores'),
|
||||
}),
|
||||
execute: async ({ path = '.', depth = 2, includeIgnored = false }) => {
|
||||
const root = jail(path);
|
||||
if (!(await isDir(root))) throw new Error(`Not a directory: ${path}`);
|
||||
|
||||
const dirs = new Set<string>();
|
||||
const files: { rel: string; size: number }[] = [];
|
||||
|
||||
for await (const rel of walk({ root, noIgnore: includeIgnored })) {
|
||||
const parts = rel.split('/');
|
||||
// Past the depth limit only the ancestors are interesting: the deepest one
|
||||
// stands in for everything under it.
|
||||
const shown = Math.min(parts.length - 1, depth);
|
||||
for (let i = 1; i <= shown; i++) dirs.add(parts.slice(0, i).join('/'));
|
||||
if (parts.length <= depth) files.push({ rel, size: Bun.file(join(root, rel)).size });
|
||||
if (files.length + dirs.size >= MAX_TREE_ENTRIES) break;
|
||||
}
|
||||
|
||||
const indent = (rel: string) => ' '.repeat(rel.split('/').length - 1);
|
||||
const name = (rel: string) => rel.split('/').at(-1)!;
|
||||
const rows = [
|
||||
...[...dirs].map((d) => ({ key: `${d}/`, line: `${indent(d)}${name(d)}/` })),
|
||||
...files.map((f) => ({ key: f.rel, line: `${indent(f.rel)}${name(f.rel)} ${humanSize(f.size)}` })),
|
||||
].sort((a, b) => a.key.localeCompare(b.key));
|
||||
|
||||
const label = path === '.' ? '.' : `${posix(path).replace(/\/+$/, '')}/`;
|
||||
if (rows.length === 0) return `${label} is empty (or everything in it is ignored).`;
|
||||
|
||||
const capped = rows.length >= MAX_TREE_ENTRIES ? `\n... [${MAX_TREE_ENTRIES}-entry limit reached]` : '';
|
||||
return cap(`${label}\n${rows.map((r) => r.line).join('\n')}${capped}`);
|
||||
},
|
||||
});
|
||||
|
||||
async function isDir(abs: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(abs)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const humanSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
type GrepArgs = { pattern: string; include?: string; ignoreCase?: boolean; includeIgnored?: boolean };
|
||||
|
||||
/**
|
||||
@@ -229,8 +385,59 @@ async function pump(
|
||||
return all;
|
||||
}
|
||||
|
||||
type Running = { command: string; proc: Bun.Subprocess; interrupted: boolean; killed?: Promise<unknown> };
|
||||
|
||||
const running = new Map<string, Running>();
|
||||
|
||||
/**
|
||||
* Kills the shell and everything it started.
|
||||
*
|
||||
* `cmd /c` and `bash -lc` run the real command as a child, and killing only the
|
||||
* shell leaves that child alive holding both pipes open — the read never ends, so
|
||||
* the interrupt looks like it did nothing until the command finishes on its own.
|
||||
* Measured at 19 seconds for `ping -n 20` on Windows.
|
||||
*
|
||||
* The promise settles once the kill is done, which also matters on Windows, where a
|
||||
* surviving grandchild keeps its working directory locked against deletion.
|
||||
*/
|
||||
function killTree(proc: Bun.Subprocess): Promise<unknown> {
|
||||
if (process.platform === 'win32' && proc.pid) {
|
||||
try {
|
||||
const taskkill = Bun.spawn(['taskkill', '/PID', String(proc.pid), '/T', '/F'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
return taskkill.exited;
|
||||
} catch {
|
||||
// taskkill missing: fall through to the plain kill below.
|
||||
}
|
||||
}
|
||||
proc.kill();
|
||||
return proc.exited;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills the commands currently in flight, leaving the turn alive.
|
||||
*
|
||||
* `esc` aborts everything, which means a runaway command can only be stopped by
|
||||
* throwing away the turn with it. This kills the process and lets `execute` throw,
|
||||
* so the model receives a tool error and takes its next step knowing what happened.
|
||||
* Returns the commands killed, for the notice shown to the user.
|
||||
*/
|
||||
export function interruptBash(): string[] {
|
||||
const killed: string[] = [];
|
||||
for (const entry of running.values()) {
|
||||
entry.interrupted = true;
|
||||
entry.killed = killTree(entry.proc);
|
||||
killed.push(entry.command);
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
export const bashTool = tool({
|
||||
description: 'Run a shell command in the workspace root. Use for builds, tests, git, and package managers.',
|
||||
description:
|
||||
'Run a shell command in the workspace root. Use for builds, tests, git, and package managers. ' +
|
||||
'Output streams live and the user can interrupt a command with ctrl-c without ending the turn.',
|
||||
inputSchema: z.object({
|
||||
command: z.string(),
|
||||
timeout: z.number().int().min(1000).max(600_000).optional().describe('Timeout in ms, default 120000'),
|
||||
@@ -245,37 +452,100 @@ export const bashTool = tool({
|
||||
...(abortSignal ? { signal: abortSignal } : {}),
|
||||
});
|
||||
|
||||
// Drained concurrently: a command that fills one pipe while we block on the
|
||||
// other would deadlock, and buffering both hides progress for minutes.
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
pump(proc.stdout as ReadableStream<Uint8Array>, toolCallId),
|
||||
pump(proc.stderr as ReadableStream<Uint8Array>, toolCallId),
|
||||
proc.exited,
|
||||
]);
|
||||
const entry: Running = { command, proc, interrupted: false };
|
||||
running.set(toolCallId, entry);
|
||||
|
||||
return cap(
|
||||
[
|
||||
`exit: ${exitCode}`,
|
||||
proc.signalCode && `(killed by ${proc.signalCode}; timeout is ${timeout}ms)`,
|
||||
stdout.trim() && `stdout:\n${stdout.trim()}`,
|
||||
stderr.trim() && `stderr:\n${stderr.trim()}`,
|
||||
]
|
||||
try {
|
||||
// Drained concurrently: a command that fills one pipe while we block on the
|
||||
// other would deadlock, and buffering both hides progress for minutes.
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
pump(proc.stdout as ReadableStream<Uint8Array>, toolCallId),
|
||||
pump(proc.stderr as ReadableStream<Uint8Array>, toolCallId),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
const body = [stdout.trim() && `stdout:\n${stdout.trim()}`, stderr.trim() && `stderr:\n${stderr.trim()}`]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
);
|
||||
.join('\n\n');
|
||||
|
||||
// Thrown rather than returned: the model must not read a killed command as
|
||||
// a command that ran and failed on its own terms.
|
||||
if (entry.interrupted) {
|
||||
throw new Error(
|
||||
cap(
|
||||
`The user interrupted this command. It did not finish, so its effects are unknown.\n${
|
||||
body || '(no output before it was killed)'
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return cap(
|
||||
[
|
||||
`exit: ${exitCode}`,
|
||||
proc.signalCode && `(killed by ${proc.signalCode}; timeout is ${timeout}ms)`,
|
||||
body,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n'),
|
||||
);
|
||||
} finally {
|
||||
// Awaited so the process really is gone before the tool returns. On Windows a
|
||||
// surviving grandchild holds the cwd open, which breaks the very next command.
|
||||
await entry.killed;
|
||||
running.delete(toolCallId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const tools = {
|
||||
read_file: readFileTool,
|
||||
read_many_files: readManyFilesTool,
|
||||
write_file: writeFileTool,
|
||||
edit_file: editFileTool,
|
||||
multi_edit: multiEditTool,
|
||||
list_dir: listDirTool,
|
||||
glob: globTool,
|
||||
grep: grepTool,
|
||||
bash: bashTool,
|
||||
...gitTools,
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool sets, so a set can be switched off before the schema cost grows.
|
||||
*
|
||||
* Measured at ~550 chars of JSON schema per tool on every request, and selection
|
||||
* accuracy falls as the list grows, so this is both a cost and a quality knob.
|
||||
* `core` is not listable here: without read, edit, and bash the agent is not an agent.
|
||||
*/
|
||||
export const TOOL_SETS = {
|
||||
core: ['read_file', 'write_file', 'edit_file', 'glob', 'grep', 'bash'],
|
||||
'edit-plus': ['multi_edit', 'list_dir', 'read_many_files'],
|
||||
git: GIT_TOOL_NAMES,
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
export type ToolSetName = keyof typeof TOOL_SETS;
|
||||
|
||||
export const TOOL_SET_NAMES = Object.keys(TOOL_SETS) as ToolSetName[];
|
||||
|
||||
export const isToolSetName = (v: string): v is ToolSetName => (TOOL_SET_NAMES as string[]).includes(v);
|
||||
|
||||
/** Which set a tool came from, for `/tools`. Session, plugin, and MCP tools have none. */
|
||||
export function toolSetOf(name: string): ToolSetName | undefined {
|
||||
return TOOL_SET_NAMES.find((set) => (TOOL_SETS[set] as readonly string[]).includes(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Names to withhold given the enabled sets. A tool belonging to no set is never
|
||||
* withheld: session, plugin, and MCP tools are not part of this budget.
|
||||
*/
|
||||
export function disabledToolNames(enabled: readonly ToolSetName[] | undefined): string[] {
|
||||
if (!enabled) return [];
|
||||
const live = new Set<ToolSetName>([...enabled, 'core']);
|
||||
return TOOL_SET_NAMES.filter((set) => !live.has(set)).flatMap((set) => [...TOOL_SETS[set]]);
|
||||
}
|
||||
|
||||
/** Tools that mutate the workspace or run arbitrary code always ask the user first. */
|
||||
export const MUTATING_TOOLS = ['write_file', 'edit_file', 'bash'] as const;
|
||||
export const MUTATING_TOOLS = ['write_file', 'edit_file', 'multi_edit', 'bash'] as const;
|
||||
|
||||
export { jail };
|
||||
|
||||
+181
-23
@@ -4,16 +4,18 @@ import Spinner from 'ink-spinner';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { parseCommand, matchCommands, type CommandSpec } from '../commands';
|
||||
import { THINKING_LEVELS, VARIANTS } from '../agents';
|
||||
import { completePath, matchPaths, pathToken } from '../complete';
|
||||
import type { Config } from '../config';
|
||||
import { TODO_MARK, type NotebookState } from '../notebook';
|
||||
import { costOf, formatUsd, usageLine } from '../pricing';
|
||||
import type { ApprovalDecision, ApprovalRequest, Session } from '../session';
|
||||
import type { SubagentEvent } from '../subagent';
|
||||
import { interruptBash, toolSetOf } from '../tools';
|
||||
import { AskPanel, type AskBridge, type AskPending } from './Ask';
|
||||
import { Diff } from './Diff';
|
||||
import { Markdown } from './Markdown';
|
||||
import { Onboard, type OnboardResult } from './Onboard';
|
||||
import { InfoPanel, OutputPanel, StatusBar, SubagentPanel, TodoPanel, type SubagentView } from './Panels';
|
||||
import { InfoPanel, OutputPanel, QueuePanel, StatusBar, SubagentPanel, ThinkingPanel, TodoPanel, ActiveTool, FileMenu, type SubagentView } from './Panels';
|
||||
import { PromptInput } from './PromptInput';
|
||||
|
||||
type Line =
|
||||
@@ -135,6 +137,8 @@ export type AppHooks = {
|
||||
saveSession: () => Promise<string>;
|
||||
/** Loaded AGENTS.md-style files, for /context. */
|
||||
instructionFiles: () => string[];
|
||||
/** Ignore-aware workspace paths for `@` completion, loaded on first use. */
|
||||
listPaths: () => Promise<string[]>;
|
||||
/** Prompt to hand the model for /init. */
|
||||
initPrompt: string;
|
||||
history: string[];
|
||||
@@ -242,7 +246,16 @@ export function App({
|
||||
const [menuIndex, setMenuIndex] = useState(0);
|
||||
const [menuDismissed, setMenuDismissed] = useState(false);
|
||||
const [inputGeneration, setInputGeneration] = useState(0);
|
||||
const [inputCursor, setInputCursor] = useState(0);
|
||||
const [toolOutput, setToolOutput] = useState('');
|
||||
const [active, setActive] = useState<{ name: string; summary?: string } | undefined>();
|
||||
const [thinking, setThinking] = useState('');
|
||||
const [thinkingOpen, setThinkingOpen] = useState(false);
|
||||
const [queue, setQueue] = useState<string[]>([]);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [paths, setPaths] = useState<string[] | undefined>();
|
||||
const [fileIndex, setFileIndex] = useState(0);
|
||||
const [fileDismissed, setFileDismissed] = useState(false);
|
||||
const [recall, setRecall] = useState<string[]>(hooks.history);
|
||||
const [notebook, setNotebook] = useState<NotebookState>(session.notebook.state());
|
||||
const [agents, setAgents] = useState<SubagentView[]>([]);
|
||||
@@ -254,6 +267,24 @@ export function App({
|
||||
const menuOpen = matches.length > 0 && !menuDismissed && !busy && !modal && !anyPicker && !panel;
|
||||
const highlighted = matches[Math.min(menuIndex, matches.length - 1)];
|
||||
|
||||
const token = pathToken(draft, cursor);
|
||||
const fileOpen = token !== undefined && !fileDismissed && !modal && !anyPicker;
|
||||
const fileMatches = token && paths ? matchPaths(paths, token.query) : [];
|
||||
const highlightedPath = fileMatches[Math.min(fileIndex, Math.max(0, fileMatches.length - 1))];
|
||||
|
||||
// The walk costs a full ignore-aware traversal, so it happens on the first `@`
|
||||
// rather than at startup, and only once.
|
||||
useEffect(() => {
|
||||
if (token === undefined || paths !== undefined) return;
|
||||
let live = true;
|
||||
void hooks.listPaths().then((all) => {
|
||||
if (live) setPaths(all);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [hooks, paths, token]);
|
||||
|
||||
useEffect(() => bridge.bind(setPending), [bridge]);
|
||||
useEffect(() => askBridge?.bind(setAsking), [askBridge]);
|
||||
|
||||
@@ -265,16 +296,31 @@ export function App({
|
||||
[subagents],
|
||||
);
|
||||
|
||||
// Ink re-renders the whole tree per setState, so deltas accumulate in a ref
|
||||
// Ink re-renders the whole tree per setState, so deltas accumulate in refs
|
||||
// and are flushed on a timer instead of once per token.
|
||||
const text = useRef('');
|
||||
const reasoning = useRef('');
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
setLive((s) => (s === text.current ? s : text.current));
|
||||
setThinking((s) => (s === reasoning.current ? s : reasoning.current));
|
||||
}, 60);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
// The queue is a ref as well as state: runTurn drains it synchronously as the
|
||||
// turn ends, and a stale closure over the array would lose a prompt.
|
||||
const queued = useRef<string[]>([]);
|
||||
const busyRef = useRef(false);
|
||||
const submitRef = useRef<((raw: string) => Promise<void>) | undefined>(undefined);
|
||||
|
||||
// Kept in step with busy, since the queue drain reads it synchronously between
|
||||
// renders and a state read there would be one turn stale.
|
||||
const setWorking = useCallback((value: boolean) => {
|
||||
busyRef.current = value;
|
||||
setBusy(value);
|
||||
}, []);
|
||||
|
||||
const push = useCallback((line: NewLine) => {
|
||||
setHistory((h) => [...h, { ...line, key: nextKey() }]);
|
||||
}, []);
|
||||
@@ -282,12 +328,31 @@ export function App({
|
||||
useEffect(() => notices?.bind((text) => push({ kind: 'info', text })), [notices, push]);
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (key.escape) session.abort();
|
||||
(input, key) => {
|
||||
// esc drops the queue too: interrupting and then watching two more prompts
|
||||
// fire anyway is not what anyone means by interrupt.
|
||||
if (key.escape) {
|
||||
queued.current.length = 0;
|
||||
setQueue([]);
|
||||
session.abort();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && input === 'r') setThinkingOpen((o) => !o);
|
||||
},
|
||||
{ isActive: busy && !modal },
|
||||
);
|
||||
|
||||
// ctrl-c kills only the command in flight, leaving the turn alive so the model
|
||||
// gets a tool error and can decide what to do. With nothing running it keeps its
|
||||
// usual meaning and quits, which is why Ink's own ctrl-c handling is turned off
|
||||
// in cli.tsx rather than left to race with this.
|
||||
useInput((input, key) => {
|
||||
if (!key.ctrl || input !== 'c') return;
|
||||
const killed = interruptBash();
|
||||
if (killed.length === 0) return exit();
|
||||
push({ kind: 'info', text: `interrupted: ${killed.join(', ')}` });
|
||||
});
|
||||
|
||||
useInput(
|
||||
(_input, key) => {
|
||||
if (!key.escape) return;
|
||||
@@ -298,14 +363,43 @@ export function App({
|
||||
{ isActive: anyPicker },
|
||||
);
|
||||
|
||||
// PromptInput hands up/down/tab/esc to us first, so the menu and any open panel
|
||||
// PromptInput hands up/down/tab/esc to us first, so the menus and any open panel
|
||||
// can claim them before the input treats them as editing keys.
|
||||
const handleInputKey = useCallback(
|
||||
(_input: string, key: { upArrow: boolean; downArrow: boolean; tab: boolean; escape: boolean }) => {
|
||||
(_input: string, key: { upArrow: boolean; downArrow: boolean; tab: boolean; escape: boolean; return: boolean }) => {
|
||||
if (key.escape && panel) {
|
||||
setPanel(undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The file picker gets first refusal: while an `@` token is open its keys
|
||||
// mean navigation, not history recall or command completion.
|
||||
if (fileOpen) {
|
||||
if (key.escape) {
|
||||
setFileDismissed(true);
|
||||
return true;
|
||||
}
|
||||
if (fileMatches.length > 0) {
|
||||
if (key.upArrow) {
|
||||
setFileIndex((i) => (i - 1 + fileMatches.length) % fileMatches.length);
|
||||
return true;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setFileIndex((i) => (i + 1) % fileMatches.length);
|
||||
return true;
|
||||
}
|
||||
if ((key.tab || key.return) && highlightedPath && token) {
|
||||
const next = completePath(draft, token, highlightedPath);
|
||||
setDraft(next.value);
|
||||
setCursor(next.cursor);
|
||||
setFileIndex(0);
|
||||
setInputCursor(next.cursor);
|
||||
setInputGeneration((g) => g + 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!menuOpen) return false;
|
||||
if (key.escape) {
|
||||
setMenuDismissed(true);
|
||||
@@ -320,47 +414,65 @@ export function App({
|
||||
return true;
|
||||
}
|
||||
if (key.tab && highlighted) {
|
||||
setDraft(highlighted.arg ? `/${highlighted.name} ` : `/${highlighted.name}`);
|
||||
const value = highlighted.arg ? `/${highlighted.name} ` : `/${highlighted.name}`;
|
||||
setDraft(value);
|
||||
setCursor(value.length);
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(true);
|
||||
setInputCursor(value.length);
|
||||
setInputGeneration((g) => g + 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[highlighted, matches.length, menuOpen, panel],
|
||||
[draft, fileMatches.length, fileOpen, highlighted, highlightedPath, matches.length, menuOpen, panel, token],
|
||||
);
|
||||
|
||||
const onDraftChange = useCallback((value: string) => {
|
||||
const onDraftChange = useCallback((value: string, at: number) => {
|
||||
setDraft(value);
|
||||
setCursor(at);
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(false);
|
||||
setFileIndex(0);
|
||||
setFileDismissed(false);
|
||||
}, []);
|
||||
|
||||
const runTurn = useCallback(
|
||||
async (value: string) => {
|
||||
setBusy(true);
|
||||
setWorking(true);
|
||||
text.current = '';
|
||||
reasoning.current = '';
|
||||
setThinking('');
|
||||
|
||||
for await (const ev of session.send(value)) {
|
||||
switch (ev.type) {
|
||||
case 'text':
|
||||
text.current += ev.text;
|
||||
break;
|
||||
case 'reasoning':
|
||||
reasoning.current += ev.text;
|
||||
break;
|
||||
case 'tool-start':
|
||||
setActive({ name: ev.name });
|
||||
break;
|
||||
case 'tool-call':
|
||||
setActive({ name: ev.name, summary: preview(ev.input) });
|
||||
push({ kind: 'tool', name: ev.name, summary: preview(ev.input), ok: true });
|
||||
break;
|
||||
case 'tool-output':
|
||||
setToolOutput((s) => `${s}${ev.chunk}`.slice(-2000));
|
||||
break;
|
||||
case 'tool-error':
|
||||
setActive(undefined);
|
||||
push({ kind: 'tool', name: ev.name, summary: String(ev.error), ok: false });
|
||||
break;
|
||||
case 'tool-result':
|
||||
setActive(undefined);
|
||||
setToolOutput('');
|
||||
setNotebook(session.notebook.state());
|
||||
break;
|
||||
case 'tool-denied':
|
||||
setActive(undefined);
|
||||
push({ kind: 'info', text: `denied ${ev.name}` });
|
||||
break;
|
||||
case 'notice':
|
||||
@@ -375,7 +487,11 @@ export function App({
|
||||
case 'done': {
|
||||
const full = text.current.trim();
|
||||
text.current = '';
|
||||
// Reasoning is progress, not the answer, so it leaves with the turn.
|
||||
reasoning.current = '';
|
||||
setThinking('');
|
||||
setLive('');
|
||||
setActive(undefined);
|
||||
setToolOutput('');
|
||||
setAgents([]);
|
||||
setHistory((h) => {
|
||||
@@ -396,16 +512,29 @@ export function App({
|
||||
break;
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
|
||||
setWorking(false);
|
||||
|
||||
// Drain one queued prompt per finished turn, in order. Going back through
|
||||
// submit means a queued slash command behaves exactly as if typed now, and
|
||||
// its own turn drains the next one.
|
||||
const next = queued.current.shift();
|
||||
if (next !== undefined) {
|
||||
setQueue([...queued.current]);
|
||||
await submitRef.current?.(next);
|
||||
}
|
||||
},
|
||||
[hooks, push, session],
|
||||
[hooks, push, session, setWorking],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async (raw: string) => {
|
||||
setDraft('');
|
||||
setCursor(0);
|
||||
setMenuIndex(0);
|
||||
setMenuDismissed(false);
|
||||
setFileIndex(0);
|
||||
setFileDismissed(false);
|
||||
setPanel(undefined);
|
||||
|
||||
// Enter on an open menu runs the highlighted entry, so `/mo` + enter works.
|
||||
@@ -421,6 +550,15 @@ export function App({
|
||||
break;
|
||||
}
|
||||
|
||||
// Typed during a turn: queue it whole, including a slash command, and let
|
||||
// the drain replay it once the model is free. Losing the thought to a
|
||||
// swallowed keystroke is the thing this exists to prevent.
|
||||
if (busyRef.current) {
|
||||
queued.current.push(chosen);
|
||||
setQueue([...queued.current]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing can reach the model until a provider is configured.
|
||||
if (unconfigured && action.type !== 'provider' && action.type !== 'info') {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
@@ -453,7 +591,10 @@ export function App({
|
||||
body: session
|
||||
.activeTools()
|
||||
.sort()
|
||||
.map((t) => `- \`${t}\``)
|
||||
.map((t) => {
|
||||
const set = toolSetOf(t);
|
||||
return `- \`${t}\`${set ? ` ${set}` : ''}`;
|
||||
})
|
||||
.join('\n'),
|
||||
});
|
||||
return;
|
||||
@@ -537,13 +678,13 @@ export function App({
|
||||
return;
|
||||
case 'memory': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
setWorking(true);
|
||||
try {
|
||||
push({ kind: 'info', text: await hooks.summarizeMemory() });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setBusy(false);
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
case 'init':
|
||||
@@ -582,9 +723,9 @@ export function App({
|
||||
return;
|
||||
case 'models': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
setWorking(true);
|
||||
const { models, warning } = await hooks.listModels();
|
||||
setBusy(false);
|
||||
setWorking(false);
|
||||
if (warning) push({ kind: 'info', text: `could not list models: ${warning}` });
|
||||
if (models.length === 0) {
|
||||
push({ kind: 'error', text: 'no models to choose from - use /model <id> or /provider' });
|
||||
@@ -595,14 +736,14 @@ export function App({
|
||||
}
|
||||
case 'compact': {
|
||||
push({ kind: 'user', text: chosen.trim() });
|
||||
setBusy(true);
|
||||
setWorking(true);
|
||||
try {
|
||||
const { before, after } = await session.summarize();
|
||||
push({ kind: 'info', text: `compacted ${before} messages into ${after}` });
|
||||
} catch (e) {
|
||||
push({ kind: 'error', text: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
setBusy(false);
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
case 'prompt':
|
||||
@@ -613,9 +754,13 @@ export function App({
|
||||
return;
|
||||
}
|
||||
},
|
||||
[exit, highlighted, hooks, menuOpen, push, runTurn, session, unconfigured, write],
|
||||
[exit, highlighted, hooks, menuOpen, push, runTurn, session, setWorking, unconfigured, write],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
submitRef.current = submit;
|
||||
}, [submit]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Static items={history}>
|
||||
@@ -752,6 +897,8 @@ export function App({
|
||||
|
||||
{busy && !modal && (
|
||||
<Box flexDirection="column">
|
||||
<ThinkingPanel text={thinking} expanded={thinkingOpen} />
|
||||
{active && <ActiveTool name={active.name} {...(active.summary ? { summary: active.summary } : {})} />}
|
||||
<OutputPanel text={toolOutput} />
|
||||
<Text color="yellow">
|
||||
<Spinner type="dots" /> <Text dimColor>working... esc to interrupt</Text>
|
||||
@@ -759,21 +906,32 @@ export function App({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!busy && !modal && !anyPicker && (
|
||||
{!modal && !anyPicker && (
|
||||
<Box flexDirection="column">
|
||||
<QueuePanel prompts={queue} />
|
||||
<Box>
|
||||
<Text color="cyan">{'> '}</Text>
|
||||
<PromptInput
|
||||
key={inputGeneration}
|
||||
value={draft}
|
||||
initialCursor={inputCursor}
|
||||
onChange={onDraftChange}
|
||||
onSubmit={submit}
|
||||
history={recall}
|
||||
onKey={handleInputKey}
|
||||
placeholder="ask shiro-neko... (/ for commands)"
|
||||
placeholder={busy ? 'type to queue for the next turn...' : 'ask shiro-neko... (/ commands, @ files)'}
|
||||
/>
|
||||
</Box>
|
||||
{menuOpen && <CommandMenu matches={matches} index={Math.min(menuIndex, matches.length - 1)} />}
|
||||
{fileOpen ? (
|
||||
<FileMenu
|
||||
paths={fileMatches}
|
||||
index={Math.min(fileIndex, Math.max(0, fileMatches.length - 1))}
|
||||
query={token?.query ?? ''}
|
||||
loading={paths === undefined}
|
||||
/>
|
||||
) : (
|
||||
menuOpen && <CommandMenu matches={matches} index={Math.min(menuIndex, matches.length - 1)} />
|
||||
)}
|
||||
<StatusBar
|
||||
model={hooks.config().model}
|
||||
agent={hooks.agentName()}
|
||||
|
||||
@@ -105,6 +105,111 @@ export function OutputPanel({ text, lines = 8 }: { text: string; lines?: number
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool call in flight, from tool-start until its result arrives.
|
||||
*
|
||||
* A read of a large file or a two-minute test run is otherwise indistinguishable
|
||||
* from a hang, and the name arrives before the arguments finish streaming, so the
|
||||
* summary is filled in a moment later.
|
||||
*/
|
||||
export function ActiveTool({ name, summary }: { name: string; summary?: string }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="magenta">
|
||||
<Spinner type="dots" />
|
||||
</Text>
|
||||
<Text>{` ${name}`}</Text>
|
||||
{summary ? <Text dimColor>{` ${summary}`}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The model's reasoning while it streams.
|
||||
*
|
||||
* Collapsed by default: it is progress, not the answer, and expanding it by default
|
||||
* would bury the reply. The token count is an estimate from character length, which
|
||||
* is close enough to tell a long think from a short one.
|
||||
*/
|
||||
export function ThinkingPanel({ text, expanded, lines = 8 }: { text: string; expanded?: boolean; lines?: number }) {
|
||||
if (text.length === 0) return null;
|
||||
const tokens = Math.round(text.length / 4);
|
||||
|
||||
if (!expanded) {
|
||||
return <Text dimColor>{`thinking... ~${tokens} tokens ctrl-r to expand`}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text dimColor>{`thinking ~${tokens} tokens ctrl-r to collapse`}</Text>
|
||||
{text
|
||||
.split('\n')
|
||||
.slice(-lines)
|
||||
.map((l, i) => (
|
||||
<Text key={i} dimColor italic>
|
||||
{` ${l}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prompts typed during a turn, waiting their place in line. */
|
||||
export function QueuePanel({ prompts }: { prompts: readonly string[] }) {
|
||||
if (prompts.length === 0) return null;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="cyan">{`queued: ${prompts.length}`}</Text>
|
||||
{prompts.map((p, i) => (
|
||||
<Text key={i} dimColor>
|
||||
{` ${i + 1}. ${p.length > 70 ? `${p.slice(0, 70)}...` : p}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Path picker for an `@` token, narrowing as the query grows. */
|
||||
export function FileMenu({
|
||||
paths,
|
||||
index,
|
||||
query,
|
||||
loading,
|
||||
}: {
|
||||
paths: readonly string[];
|
||||
index: number;
|
||||
query: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>indexing files...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (paths.length === 0) {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{`no file matches ${query || '@'}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{paths.map((p, i) => (
|
||||
<Text key={p} color={i === index ? 'cyan' : undefined} dimColor={i !== index}>
|
||||
{i === index ? '> ' : ' '}
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
<Text dimColor>up/down move | tab or enter insert | esc dismiss</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Status line under the transcript: model, agent, thinking, context, spend. */
|
||||
export function StatusBar({
|
||||
model,
|
||||
|
||||
@@ -3,7 +3,8 @@ import React, { useEffect, useState } from 'react';
|
||||
|
||||
export type PromptInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Cursor is reported alongside the value: `@path` completion needs to know where it is. */
|
||||
onChange: (value: string, cursor: number) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
placeholder?: string;
|
||||
focus?: boolean;
|
||||
@@ -12,6 +13,8 @@ export type PromptInputProps = {
|
||||
history?: readonly string[];
|
||||
/** Intercept a key before the input consumes it. Return true to swallow it. */
|
||||
onKey?: (input: string, key: KeyLike) => boolean;
|
||||
/** Where to put the cursor on mount, for a remounted input after a completion. */
|
||||
initialCursor?: number;
|
||||
};
|
||||
|
||||
type KeyLike = {
|
||||
@@ -51,8 +54,9 @@ export function PromptInput({
|
||||
mask,
|
||||
history = [],
|
||||
onKey,
|
||||
initialCursor,
|
||||
}: PromptInputProps) {
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
const [cursor, setCursor] = useState(initialCursor ?? value.length);
|
||||
// -1 means "editing a fresh line"; 0+ indexes back from the newest entry.
|
||||
const [recall, setRecall] = useState(-1);
|
||||
const [stash, setStash] = useState('');
|
||||
@@ -62,8 +66,9 @@ export function PromptInput({
|
||||
}, [value]);
|
||||
|
||||
const set = (next: string, nextCursor = next.length) => {
|
||||
onChange(next);
|
||||
setCursor(Math.max(0, Math.min(nextCursor, next.length)));
|
||||
const clamped = Math.max(0, Math.min(nextCursor, next.length));
|
||||
onChange(next, clamped);
|
||||
setCursor(clamped);
|
||||
};
|
||||
|
||||
useInput(
|
||||
|
||||
Reference in New Issue
Block a user