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
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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');
/**
+68
View File
@@ -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 };
}
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+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);
+297 -27
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+9 -4
View File
@@ -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(