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
+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(