Compact terminal tool output while Claude's native thinking display remains visible.

Compact Bash, Read, Edit, Write, and MCP output for Claude Code's terminal, with a visible ON/OFF toggle above the prompt. Run /compact-tools to toggle, or /compact-tools on and /compact-tools off.
Failed Bash calls show a compact red error row with their exit code when available and first diagnostic. Turn the mod off to see full error output.
MCP calls show the server and tool name without dumping their JSON arguments. Results show the first text line and a hidden-line count, or a count of non-text items. Failures are marked red, and interrupted calls keep their native interruption marker. The complete arguments and results still reach the tool and model and remain in the transcript; turning the mod off restores their full display.
Install sidebar-controls to move the ON/OFF button into a top-right sidebar. Closing the sidebar returns it above the prompt.
Install from the marketplace:
/plugin marketplace add AJclemendor/my-mods
/plugin install compact-tools@my-mods
/reload-plugins
Thinking visibility is configured separately in Claude's settings. See the marketplace README for setup, compatibility, and update instructions.
hooks/compact-tools.mjs 200 lines1const enabledState = { plugin: "compact-tools", key: "enabled" };
2const supported = new Set(["Bash", "Read", "Edit", "Write"]);
3
4export function register(on) {
5 on("session.start", async ($, e, next) => {
6 const result = await next(e);
7 await $.command.register({
8 name: "compact-tools",
9 description: "Toggle compact tool output; thinking stays in Claude's native display",
10 argumentHint: "[on|off]",
11 immediate: true,
12 });
13 return result;
14 });
15
16 on("command.run", { command: "compact-tools" }, async ($, e) => {
17 const arg = e.args.trim().toLowerCase();
18 if (arg && arg !== "on" && arg !== "off") {
19 return { text: "Usage: /compact-tools [on|off]" };
20 }
21 const { value = true } = await $.state.get(enabledState);
22 const enabled = arg ? arg === "on" : !value;
23 await $.state.set(enabledState, enabled);
24 return { text: `Compact tools ${enabled ? "on" : "off"}. Thinking display is unchanged.` };
25 });
26
27 on("ui.render", { component: ["AbovePrompt", "Pane"] }, async ($, e, next) => {
28 const inSidebar = e.component === "Pane" && e.requestId === "sidebar-controls";
29 if (!inSidebar && e.component !== "AbovePrompt") return next(e);
30 const original = await next(e);
31 if (e.surface !== "terminal" || e.props.hasSurvey) return original;
32 if (!inSidebar) {
33 const { value: sidebarVisible } = await $.state.get({ plugin: "sidebar-controls", key: "visible" });
34 if (sidebarVisible) return original;
35 }
36 const { value = true } = await $.state.get(enabledState);
37 const { Box, Button } = $.ui.resolve(e);
38 return Box({
39 flexDirection: "column",
40 children: [original, Button({
41 key: "toggle-compact-tools",
42 label: `Compact tools: ${value ? "ON" : "OFF"}`,
43 color: value ? "green" : "gray",
44 onPress: async () => {
45 const { value = true } = await $.state.get(enabledState);
46 await $.state.set(enabledState, !value);
47 },
48 })],
49 });
50 });
51
52 on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
53 const p = e.props;
54 if (e.surface === "terminal" && isMcp(p.tool) && !p.isInterrupted) {
55 const { value = true } = await $.state.get(enabledState);
56 if (!value || isInterruption(p.output)) return next(e);
57 const failed = p.isErrored || p.output?.isError === true;
58 const name = p.tool.slice(5).split("__").map(part => part.replace(/_/g, " ")).join(" · ");
59 const label = `${failed ? "✗" : "●"} ${oneLine(name)} (MCP)${p.isRunning ? " · Running…" : ""}`;
60 return $.ui.resolve(e).Box({
61 flexDirection: "column",
62 marginTop: 1,
63 children: [row($, e, label, false, failed), ...(p.isRunning ? [] : [
64 row($, e, ` ⎿ ${failed ? "Error · " : ""}${mcpResultLabel(p.output, failed)}`, true, failed),
65 ])],
66 });
67 }
68 if (e.surface !== "terminal" || !supported.has(p.tool) || (p.isErrored && p.tool !== "Bash") || p.isInterrupted) {
69 return next(e);
70 }
71 const { value = true } = await $.state.get(enabledState);
72 if (!value) return next(e);
73 const label = toolLabel(p);
74 if (label === null) return next(e);
75 const result = p.isErrored ? bashErrorLabel(p.output) : resultLabel(p.tool, p.output);
76 if ((p.isErrored || p.output !== undefined) && result === null) return next(e);
77 const { Box } = $.ui.resolve(e);
78 return Box({
79 flexDirection: "column",
80 marginTop: 1,
81 children: [row($, e, label, false, p.isErrored), ...(result === null ? [] : [row($, e, ` ⎿ ${result}`, true, p.isErrored)])],
82 });
83 });
84
85 on("ui.render", { component: "ToolResult" }, async ($, e, next) => {
86 const p = e.props;
87 if (e.surface === "terminal" && isMcp(p.tool)) {
88 const { value = true } = await $.state.get(enabledState);
89 if (!value || isInterruption(p.output)) return next(e);
90 return $.ui.resolve(e).Box({});
91 }
92 if (e.surface !== "terminal" || !supported.has(p.tool) || (p.isErrored && p.tool !== "Bash")) return next(e);
93 const { value = true } = await $.state.get(enabledState);
94 if (!value) return next(e);
95 const label = p.isErrored ? bashErrorLabel(p.output) : resultLabel(p.tool, p.output);
96 if (label === null) return next(e);
97 // Both layouts receive the result on ToolUse; the standalone result would repeat it.
98 return $.ui.resolve(e).Box({});
99 });
100}
101
102function isMcp(tool) {
103 return tool.startsWith("mcp__");
104}
105
106function isInterruption(output) {
107 return typeof output === "string" && /^(?:Error:\s*)?\[(?:Request interrupted|Tool call )/i.test(output.trim());
108}
109
110function mcpResultLabel(output, failed) {
111 const blocks = Array.isArray(output) ? output : Array.isArray(output?.content) ? output.content : [];
112 const texts = typeof output === "string" ? [output] : blocks.filter(block => block?.type === "text" && typeof block.text === "string").map(block => block.text);
113 const lines = texts.flatMap(text => text.split(/\r?\n/)).map(oneLine).filter(Boolean);
114 const attachments = blocks.filter(block => block?.type !== "text").length;
115 const first = failed ? lines[0]?.replace(/^Error:\s*/i, "") : lines[0];
116 const fallback = failed ? "Tool failed" : attachments ? `${attachments} non-text ${attachments === 1 ? "item" : "items"}`
117 : output?.structuredContent || (output && !Array.isArray(output) && typeof output === "object" && !Array.isArray(output.content)) ? "Structured result" : "No output";
118 return `${first || fallback}${lines.length > 1 ? ` … +${lines.length - 1} lines` : ""}${lines.length && attachments ? ` · ${attachments} non-text ${attachments === 1 ? "item" : "items"}` : ""}`;
119}
120
121function toolLabel(p) {
122 const input = p.input;
123 if (!input || typeof input !== "object") return null;
124 const label = p.tool === "Bash" ? input.description || input.command : input.file_path;
125 if (typeof label !== "string") return null;
126 return `${p.isErrored ? "✗" : "●"} ${p.tool}(${oneLine(label)})${p.isRunning ? " · Running…" : ""}`;
127}
128
129function oneLine(value) {
130 return value
131 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
132 .replace(/[\r\n\t]+/g, " ")
133 .replace(/[\x00-\x1f\x7f-\x9f]/g, "")
134 .trim()
135 .slice(0, 2000);
136}
137
138function row($, e, label, dimColor, isError = false) {
139 const { Box, Text } = $.ui.resolve(e);
140 return Box({
141 height: 1,
142 children: [Text({ dimColor: isError ? false : dimColor, color: isError ? "red" : undefined, wrap: "truncate-end", children: label })],
143 });
144}
145
146function linesIn(text) {
147 return text === "" ? 0 : text.replace(/\r?\n$/, "").split(/\r?\n/).length;
148}
149
150function lineCount(count) {
151 return `${count} ${count === 1 ? "line" : "lines"}`;
152}
153
154function bashErrorLabel(output) {
155 if (typeof output !== "string") {
156 const summary = resultLabel("Bash", output);
157 return summary === null ? null : `Error · ${summary}`;
158 }
159 if (/^(?:Error:\s*)?\[(?:Request interrupted|Tool call )/i.test(output.trim())) return null;
160 const lines = output.split(/\r?\n/).map(oneLine).filter(Boolean);
161 const exit = lines[0]?.match(/^(?:Error:\s*)?Exit code (\d+)$/i);
162 if (exit) lines.shift();
163 const detail = lines[0]?.replace(/^Error:\s*/i, "") || "Command failed";
164 return `${exit ? `Exit code ${exit[1]}` : "Error"} · ${detail}${lines.length > 1 ? ` … +${lines.length - 1} lines` : ""}`;
165}
166
167function resultLabel(tool, output) {
168 if (!output || typeof output !== "object") return null;
169 if (output.staged || output.interrupted) return null;
170 if (tool === "Bash") {
171 if (typeof output.stdout !== "string" || typeof output.stderr !== "string") return null;
172 const lines = [output.stderr, output.stdout].join("\n")
173 .split(/\r?\n/).map(oneLine).filter(Boolean);
174 const fallback = output.isImage ? "Image sent to Claude"
175 : output.backgroundTaskId ? "Running in background"
176 : output.bashEditDiff ? "File updated"
177 : output.returnCodeInterpretation || "No output";
178 return `${output.stderr.trim() ? "stderr · " : ""}${lines[0] || oneLine(fallback)}${lines.length > 1 ? ` … +${lines.length - 1} lines` : ""}`;
179 }
180 if (tool === "Edit") {
181 if (!Array.isArray(output.structuredPatch)) return null;
182 let added = 0, removed = 0;
183 for (const hunk of output.structuredPatch) {
184 for (const line of hunk.lines) {
185 if (line.startsWith("+")) added++;
186 if (line.startsWith("-")) removed++;
187 }
188 }
189 return `Added ${lineCount(added)}, removed ${lineCount(removed)}`;
190 }
191 if (tool === "Write") {
192 if (typeof output.content !== "string") return null;
193 return `Wrote ${lineCount(linesIn(output.content))}`;
194 }
195 if (tool === "Read" && output.type === "text" && typeof output.file?.numLines === "number") {
196 return `Read ${lineCount(output.file.numLines)}${output.file.truncatedByTokenCap ? " (partial)" : ""}`;
197 }
198 return null;
199}
200types/index.d.ts 7 lines1declare module "claude-code" {
2 interface PluginState {
3 "sidebar-controls": { visible: boolean };
4 "compact-tools": { enabled: boolean };
5 }
6}
7