Side pane listing tool calls as Claude makes them

A toolkit that gives AI coding agents specialized roles, reusable skills and structured workflows, linked into any project with one command.
agents/: persona definitions with behavioral instructions.skills/: reusable capabilities (workflows, style guides, analysis patterns).commands/: slash-command templates.instructions/: domain rules and conventions per language or framework.hooks/: hook scripts wired into the agent harnesses.src/: MCP servers and Python tooling.git, curl, uv, Node.js 24 or newer, and the Claude CLI.[!WARNING] The installer changes files outside this repo.
- It edits your shell startup files (
~/.zshrcand~/.bashrc):PATH,AI_PROJECT_ROOT_DIR,CLAUDE_CONFIG_DIRand the alias file are added.- Global harness config files become symlinks into this repo (
~/.claude/settings.json,~/.gemini/settings.json,~/.copilot/mcp-config.json,~/.copilot/config.json). A regular file already at one of these paths is kept beside it as<path>.bak; a folder at that path is left untouched and a warning is printed.
Installation has two stages.
1. One-time bootstrap, run inside the clone. It installs dependencies and tools and creates the aipp command:
git clone https://github.com/Ronnasayd/aipp.git ~/aipp
cd ~/aipp
bash install.sh
2. Per project, run aipp from the project root with a backend flag:
cd ~/my-project
aipp --claude
| Flag | Effect |
|---|---|
--all | Install every backend (default with no flag) |
--claude | Install the Claude backend |
--copilot | Install the Copilot backend |
--gemini | Install the Gemini backend |
--codex | Install the Codex backend |
--antigravity | Install the Antigravity backend |
--taskmaster | Install the taskmaster config |
--gitignore | Install the gitignore entries |
--clean | Remove the symlinks created by the install scripts |
--help, -h | Show the help screen |
To run the tests, see CONTRIBUTING.md.
aipp <flag> symlinks the agents, skills, commands and instructions into the project's harness folder (for example .claude/). Which items are linked is a per-project toggle stored in $CLAUDE_CONFIG_DIR/aipp-settings.json, seeded from the repo defaults the first time a project is installed. The mif alias opens a selection tool that enables or disables items; mia runs it and then installs the Claude backend.
Defined in .aipp.alias.zshrc and sourced from your shell startup files:
| Alias | What it does |
|---|---|
mif | Toggle skills, agents and instructions for the current project |
mia | Run mif, then aipp --claude |
ats, ata, atr | Show enabled skills, agents and instructions of the current project |
lgh | Follow the hooks log |
aij | Run the ai-jail sandbox script |
bash uninstall.sh
It removes the aipp command, runs the harness uninstall, and strips the lines the installer added to ~/.zshrc and ~/.bashrc (each edited file is saved as <rc>.bak).
It does not restore your .bak config files; the global config paths stay as symlinks into this repo. To restore one by hand:
mv ~/.claude/settings.json.bak ~/.claude/settings.json
agents/, skills/, commands/, instructions/, hooks/: the shared catalog described above.src/: MCP servers and Python tooling.scripts/: install, clean and setup scripts per harness.claude/, gemini/, github-copilot/, antigravity/: harness-specific configs and settings.taskmaster/: task tracking config.docs/: reference and supporting documentation.docker/: Docker compose configs (for example the LiteLLM proxy).legacy/: archived configs kept for reference.tlc-harness-toolkit/: TLC harness tooling and config.install.sh, uninstall.sh: entry-point installer and uninstaller.AGENTS.md: canonical instructions for AI agents working in this repo.hooks/register.tsx 475 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type {
5 AgentMessageRecord,
6 ToolCallRecord,
7 ToolHistoryTab
8} from "../types";
9
10const PANE = "tool-history";
11const OUTPUT_DIR = "/tmp/tool-history";
12const calls = atom({ plugin: "tool-history", key: "calls" } as const, []);
13const messages = atom({ plugin: "tool-history", key: "messages" } as const, []);
14const isOpen = atom({ plugin: "tool-history", key: "isOpen" } as const, false);
15const isCollapsed = atom(
16 { plugin: "tool-history", key: "isCollapsed" } as const,
17 false
18);
19const activeTab = atom(
20 { plugin: "tool-history", key: "activeTab" } as const,
21 "calls" as ToolHistoryTab
22);
23// why: full, untruncated results keyed by tool_use_id, so the pane can write
24// them out on click without re-running the tool or growing `calls` itself.
25const fullResults = atom(
26 { plugin: "tool-history", key: "fullResults" } as const,
27 {} as Record<string, string>
28);
29// why: passive auto-scroll (new tool call/message) should not yank the
30// window back down once the person has scrolled up to read something.
31const isAtBottom = atom(
32 { plugin: "tool-history", key: "isAtBottom" } as const,
33 true
34);
35
36/**
37 * Builds a one-line summary of a tool call's arguments for display in the pane.
38 * @param e - The tool.call event, carrying `tool` and that tool's own args spread on it.
39 * @returns A short string describing the call, or empty string if there is nothing notable to show.
40 */
41const SUMMARY_CHARS_SHOWN = 300;
42
43function summarize(e: Record<string, unknown>): string {
44 const raw =
45 (typeof e.command === "string" && e.command) ||
46 (typeof e.file_path === "string" && e.file_path) ||
47 (typeof e.pattern === "string" && e.pattern) ||
48 (typeof e.url === "string" && e.url) ||
49 (typeof e.query === "string" && e.query) ||
50 (typeof e.description === "string" && e.description) ||
51 "";
52 if (!raw) return raw;
53 const oneLine = raw.replace(/\s+/g, " ").trim();
54 return oneLine.length <= SUMMARY_CHARS_SHOWN
55 ? oneLine
56 : `${oneLine.slice(0, SUMMARY_CHARS_SHOWN)}…`;
57}
58
59const RESULT_LINES_SHOWN = 4;
60// why: engine's ui.render chokes past ~100k chars in one draw (observed crash)
61const RESULT_CHARS_SHOWN = 2000;
62
63/**
64 * Resolves a human-readable label for the agent loop a tool call ran in.
65 * @param $ - The engine interface, used to list known agents.
66 * @param agentId - The loop's agent id, or undefined for the main loop.
67 * @returns "Main" for the main loop, or the agent's name/type/id.
68 */
69async function resolveAgentLabel(
70 $: EngineInterface,
71 agentId: string | undefined
72): Promise<string> {
73 if (!agentId) return "Main";
74 const agents = await $.agent.list();
75 const info = agents.find((a) => a.id === agentId);
76 if (!info) return agentId;
77 return info.name ?? info.type ?? info.description ?? agentId;
78}
79
80// why: unfiltered ANSI/control bytes (e.g. from MCP console-log tools) can
81// throw while the pane draws, which makes the engine render it empty.
82// eslint-disable-next-line no-control-regex
83const ANSI_AND_CONTROL = /\x1b\[[0-9;]*[a-zA-Z]|[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
84
85/**
86 * Trims a tool result's text down to the first few lines for pane display.
87 * @param text - The raw result text, or undefined when the call produced none.
88 * @returns The trimmed text, or undefined when there was nothing to show.
89 */
90function trimResult(text: string | undefined): string | undefined {
91 if (!text) return undefined;
92 const clean = text.replace(ANSI_AND_CONTROL, "");
93 const lines = clean.split("\n");
94 const byLines =
95 lines.length <= RESULT_LINES_SHOWN
96 ? clean
97 : [
98 ...lines.slice(0, RESULT_LINES_SHOWN),
99 `… +${lines.length - RESULT_LINES_SHOWN} lines`
100 ].join("\n");
101 if (byLines.length <= RESULT_CHARS_SHOWN) return byLines;
102 return `${byLines.slice(0, RESULT_CHARS_SHOWN)}… +${byLines.length - RESULT_CHARS_SHOWN} chars`;
103}
104
105// invariant: result written to disk first — OS handler opens a file path, not stdin/stdout
106async function openFullResult(
107 $: EngineInterface,
108 call: ToolCallRecord
109): Promise<void> {
110 const map = await read($, fullResults);
111 const text = map[call.id];
112 if (!text) return;
113 const path = `${OUTPUT_DIR}/${call.id}.md`;
114 const doc = [
115 `# ${call.tool}`,
116 "",
117 "## Command",
118 "",
119 "```",
120 call.summary || "(no arguments)",
121 "```",
122 "",
123 "## Result",
124 "",
125 "```",
126 text,
127 "```"
128 ].join("\n");
129 await $.fs.write(path, doc);
130 // why: no platform-detection API is exposed to plugins; try both openers.
131 await $.process
132 .run(["xdg-open", path])
133 .catch(() => $.process.run(["open", path]))
134 .catch(() => {});
135}
136
137// why: fire-and-forget; with no surface attached the scroll rejects, and an
138// unhandled rejection there must not fail the hook that asked for it.
139function scrollToEnd($: EngineInterface) {
140 $.ui.scroll({ to: "end", in: PANE }).catch(() => {});
141}
142
143// why: the dock never narrows below 24 columns, so collapsing closes the pane
144// outright and leaves the reopen button in the band above the prompt.
145async function setCollapsed($: EngineInterface, collapsed: boolean) {
146 await update($, isCollapsed, () => collapsed);
147 await update($, isOpen, () => !collapsed);
148 if (collapsed) {
149 await $.ui.close({ id: PANE });
150 return;
151 }
152 await $.ui.open({ id: PANE, title: "Tool calls" });
153 scrollToEnd($);
154}
155
156export const register: Register = (on) => {
157 on("session.start", async ($, e, next) => {
158 await $.command.register({
159 name: "tool-history",
160 description: "Toggle the tool calls pane for this session"
161 });
162 scrollToEnd($);
163
164 return next(e);
165 });
166
167 on("command.run", { command: "tool-history" }, async ($) => {
168 const open = await read($, isOpen);
169 if (open) {
170 await $.ui.close({ id: PANE });
171 await update($, isOpen, () => false);
172 return { text: "Tool calls pane closed." };
173 }
174
175 await update($, isCollapsed, () => false);
176 await $.ui.open({ id: PANE, title: "Tool calls" });
177 await update($, isOpen, () => true);
178 scrollToEnd($);
179 return { text: "Tool calls pane opened." };
180 });
181
182 on(
183 "ui.scroll",
184 { component: "Pane", requestId: PANE },
185 async ($, e, next) => {
186 const maxOffset = Math.max(0, e.contentRows - e.bodyRows);
187 await update($, isAtBottom, () => e.offset >= maxOffset);
188 return next(e);
189 }
190 );
191
192 on("tool.call", async ($, e, next) => {
193 const record: ToolCallRecord = {
194 id: e.tool_use_id,
195 tool: e.tool,
196 summary: summarize(e as unknown as Record<string, unknown>),
197 isDone: false,
198 agentId: e.agentId,
199 agentLabel: await resolveAgentLabel($, e.agentId)
200 };
201 await update($, calls, (list) => [...list, record].slice(-200));
202 const ran = await next(e);
203 const rawText = "text" in ran ? ran.text : undefined;
204 const resultText = trimResult(rawText);
205 const isError = "isError" in ran ? ran.isError : undefined;
206 let trimmed: ToolCallRecord[] = [];
207 await update($, calls, (list) => {
208 trimmed = list.map((one) =>
209 one.id === record.id
210 ? { ...one, isDone: true, resultText, isError }
211 : one
212 );
213 return trimmed;
214 });
215 if (rawText) {
216 const liveIds = new Set(trimmed.map((one) => one.id));
217 await update($, fullResults, (map) => {
218 const nextMap: Record<string, string> = { [record.id]: rawText };
219 for (const [id, text] of Object.entries(map)) {
220 if (liveIds.has(id)) nextMap[id] = text;
221 }
222 return nextMap;
223 });
224 }
225
226 if ((await read($, isOpen)) && (await read($, isAtBottom))) {
227 scrollToEnd($);
228 }
229
230 return ran;
231 });
232
233 on("prompt.submit", async ($, e, next) => {
234 if (!e.isMeta) {
235 const record: AgentMessageRecord = {
236 id: `user-${Date.now()}-${Math.random().toString(36).slice(2)}`,
237 kind: "user",
238 text: e.text,
239 agentLabel: "Main"
240 };
241 await update($, messages, (list) => [...list, record].slice(-200));
242 }
243 return next(e);
244 });
245
246 on("turn.step", async function* ($, e, next) {
247 const agentLabel = await resolveAgentLabel($, e.agentId);
248 const buffers = new Map<
249 number,
250 { kind: "text" | "thinking"; text: string }
251 >();
252
253 const stream = next(e);
254 for await (const chunk of stream) {
255 if (chunk.kind === "text" || chunk.kind === "thinking") {
256 const buf = buffers.get(chunk.index);
257 if (buf && buf.kind === chunk.kind) buf.text += chunk.text;
258 else buffers.set(chunk.index, { kind: chunk.kind, text: chunk.text });
259 }
260 yield chunk;
261 }
262
263 const turnId = e.turnId;
264 const newRecords: AgentMessageRecord[] = [...buffers.entries()]
265 .sort(([a], [b]) => a - b)
266 .filter(([, buf]) => buf.text.trim().length > 0)
267 .map(([index, buf]) => ({
268 id: `${turnId}-${index}`,
269 turnId,
270 kind: buf.kind,
271 text: buf.text,
272 agentId: e.agentId,
273 agentLabel
274 }));
275
276 if (newRecords.length > 0) {
277 await update($, messages, (list) => [...list, ...newRecords].slice(-200));
278 if (
279 (await read($, isOpen)) &&
280 (await read($, activeTab)) === "messages" &&
281 (await read($, isAtBottom))
282 ) {
283 scrollToEnd($);
284 }
285 }
286 });
287
288 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
289 if (e.props.hasSurvey) return next(e);
290 if (await read($, isCollapsed)) {
291 const { Button } = $.ui.resolve(e);
292 return (
293 <Button key="expand" plain onPress={() => setCollapsed($, false)}>
294 » Tool-History
295 </Button>
296 );
297 }
298 if (!(await read($, isOpen))) return next(e);
299
300 // why: the pane body scrolls; the band is the only spot that stays
301 // visible while the person reads further up, so the toolbar lives here.
302 const { Box, Button } = $.ui.resolve(e);
303 const tab = await read($, activeTab);
304 const atBottom = await read($, isAtBottom);
305 const selectTab = (target: ToolHistoryTab) => async () => {
306 await update($, activeTab, () => target);
307 scrollToEnd($);
308 };
309 return (
310 <Box
311 key="toolbar"
312 flexDirection="row"
313 gap={1}
314 borderStyle="round"
315 borderColor="subtle"
316 paddingX={1}
317 alignSelf="flex-start"
318 >
319 <Button
320 key="calls"
321 plain
322 variant={tab === "calls" ? "primary" : "secondary"}
323 onPress={selectTab("calls")}
324 >
325 🔧
326 </Button>
327 <Button
328 key="messages"
329 plain
330 variant={tab === "messages" ? "primary" : "secondary"}
331 onPress={selectTab("messages")}
332 >
333 💬
334 </Button>
335 <Button key="collapse" plain onPress={() => setCollapsed($, true)}>
336 📋
337 </Button>
338 {!atBottom && (
339 <Button key="to-end" plain onPress={() => scrollToEnd($)}>
340 ⬇ fim
341 </Button>
342 )}
343 </Box>
344 );
345 });
346
347 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
348 const { Box, Text, Button, Markdown } = $.ui.resolve(e);
349 const tab = await read($, activeTab);
350
351 if (tab === "messages") {
352 const list = await read($, messages);
353 const groups: { agentLabel: string; messages: AgentMessageRecord[] }[] =
354 [];
355 for (const msg of list) {
356 const lastGroup = groups[groups.length - 1];
357 if (lastGroup && lastGroup.agentLabel === msg.agentLabel) {
358 lastGroup.messages.push(msg);
359 } else {
360 groups.push({ agentLabel: msg.agentLabel, messages: [msg] });
361 }
362 }
363 return (
364 <Box key="root" flexDirection="column">
365 <Box key="list" flexDirection="column" gap={1}>
366 {list.length === 0 && <Text dimColor>No agent messages yet.</Text>}
367 {groups.map((group, groupIndex) => (
368 <Box
369 key={`${group.agentLabel}-${groupIndex}`}
370 flexDirection="column"
371 >
372 <Text bold dimColor>
373 ── {group.agentLabel} ──
374 </Text>
375 {group.messages.map((msg) => (
376 <Box
377 key={msg.id}
378 flexDirection="column"
379 borderStyle="round"
380 paddingX={1}
381 borderColor={
382 msg.kind === "user"
383 ? "green"
384 : msg.kind === "thinking"
385 ? "subtle"
386 : "claude"
387 }
388 >
389 <Text bold dimColor>
390 {msg.kind === "user" ? "User" : msg.kind}
391 </Text>
392 <Markdown
393 text={msg.text}
394 dimColor={msg.kind === "thinking"}
395 />
396 </Box>
397 ))}
398 </Box>
399 ))}
400 </Box>
401 </Box>
402 );
403 }
404
405 const list = await read($, calls);
406 const results = await read($, fullResults);
407 const groups: { agentLabel: string; calls: ToolCallRecord[] }[] = [];
408 for (const call of list) {
409 const lastGroup = groups[groups.length - 1];
410 if (lastGroup && lastGroup.agentLabel === call.agentLabel) {
411 lastGroup.calls.push(call);
412 } else {
413 groups.push({ agentLabel: call.agentLabel, calls: [call] });
414 }
415 }
416
417 return (
418 <Box key="root" flexDirection="column">
419 <Box key="list" flexDirection="column" gap={1}>
420 {list.length === 0 && <Text dimColor>No tool calls yet.</Text>}
421 {groups.map((group, groupIndex) => (
422 <Box
423 key={`${group.agentLabel}-${groupIndex}`}
424 flexDirection="column"
425 >
426 <Text bold dimColor>
427 ── {group.agentLabel} ──
428 </Text>
429 {group.calls.map((call) => {
430 const isBash = call.tool === "Bash";
431 const icon = call.isDone ? (isBash ? "$" : "✓") : "⚙";
432 const borderColor = call.isError
433 ? "danger"
434 : call.isDone
435 ? "subtle"
436 : "claude";
437 return (
438 <Box
439 key={call.id}
440 flexDirection="column"
441 borderStyle="round"
442 paddingX={1}
443 borderColor={borderColor}
444 >
445 <Box flexDirection="row" gap={1}>
446 <Text bold color={borderColor}>
447 {icon} {call.tool.toUpperCase()}
448 </Text>
449 {results[call.id] && (
450 <Button
451 key={`open-${call.id}`}
452 plain
453 onPress={() => openFullResult($, call)}
454 >
455 [{call.id.slice(-8)}]
456 </Button>
457 )}
458 </Box>
459 {call.summary && <Text color="yellow">{call.summary}</Text>}
460 {call.resultText && (
461 <Text color="cyan" dimColor={call.isDone}>
462 {call.resultText}
463 </Text>
464 )}
465 </Box>
466 );
467 })}
468 </Box>
469 ))}
470 </Box>
471 </Box>
472 );
473 });
474};
475types/index.d.ts 36 lines1export type ToolCallRecord = {
2 id: string;
3 tool: string;
4 summary: string;
5 isDone: boolean;
6 resultText?: string;
7 isError?: boolean;
8 agentId?: string;
9 agentLabel: string;
10};
11
12export type AgentMessageRecord = {
13 id: string;
14 turnId?: string;
15 kind: "text" | "thinking" | "user";
16 text: string;
17 agentId?: string;
18 agentLabel: string;
19};
20
21export type ToolHistoryTab = "calls" | "messages";
22
23declare module "claude-code" {
24 interface PluginState {
25 "tool-history": {
26 calls: ToolCallRecord[];
27 messages: AgentMessageRecord[];
28 isOpen: boolean;
29 isCollapsed: boolean;
30 isAtBottom: boolean;
31 activeTab: ToolHistoryTab;
32 fullResults: Record<string, string>;
33 };
34 }
35}
36