Collapses tool results (MCP included) to one line; a click opens the full call in a side pane

Personal Claude Code mods, published as one plugin marketplace (claude-customizations).
| Mod | What it does |
|---|---|
| unclutter | Collapses tool and MCP results to one line; details open in a side pane with the goal, highlighted input and output. j/k walk the calls. |
Add the working copy as a marketplace. Claude Code then reads each mod straight from this folder: no symlinks, no copies.
claude plugin marketplace add ~/workspace/personal/claude-customizations
claude plugin install unclutter@claude-customizations --scope user
After editing a mod, run /reload-plugins in Claude Code to pick up the change.
From GitHub, typed at the Claude Code prompt:
/plugin install unclutter --marketplace <owner>/claude-customizations
Or from any git remote:
claude plugin marketplace add <git-url>
claude plugin install unclutter@claude-customizations --scope user
These installs run a copy made at install time. Pull in new commits with claude plugin marketplace update claude-customizations, then claude plugin update unclutter@claude-customizations and /reload-plugins.
Each mod's options are in /config, or:
claude plugin configure unclutter@claude-customizations
.claude-plugin/marketplace.json lists every mod in this repo
mods/<mod>/ one folder per mod
.claude-plugin/plugin.json manifest: name, version, userConfig options
hooks/hooks.json points at the hooks module
hooks/register.tsx the mod itself
types/index.d.ts $.state contract (when the mod keeps state)
tests/*.test.ts run with `claude plugin test`
scripts/check.sh validates the marketplace and tests every mod
mods/<mod>/ with at least .claude-plugin/plugin.json, hooks/hooks.json and hooks/register.tsx..claude-plugin/marketplace.json: { "name": "<mod>", "source": "./mods/<mod>" }../scripts/check.sh <mod>.claude plugin marketplace update claude-customizations, then claude plugin install <mod>@claude-customizations --scope user.Bump version in a mod's plugin.json when you change it, so claude plugin update picks it up on other machines.
hooks/register.tsx 490 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMessage } from 'claude-code'
3
4import type { ToolDetail } from '../types'
5
6const PANE = 'tool-detail'
7const PANE_TITLE = 'Tool call'
8const FOLLOW_COMMAND = 'tool-pane-follow'
9const OPEN_PANE_ACTION = 'diff:back'
10const MAX_CHARS = 200_000
11const MAX_GOAL_CHARS = 1_000
12const ENVELOPE_KEYS = ['agentId', 'requestMeta', 'consent']
13const SQL_KEYS = ['query', 'sql']
14const HIGHLIGHT_BACKGROUNDS = { dark: '#3b4252', light: '#dde3ec' }
15
16const selected = atom({ plugin: 'unclutter', key: 'selected' } as const, null)
17const followOverride = atom({ plugin: 'unclutter', key: 'followOverride' } as const, null)
18const inputs = atom({ plugin: 'unclutter', key: 'inputs' } as const, '')
19const outputs = atom({ plugin: 'unclutter', key: 'outputs' } as const, '')
20const goals = atom({ plugin: 'unclutter', key: 'goals' } as const, '')
21const highlighted = atom({ plugin: 'unclutter', key: 'highlighted' } as const, false)
22const shortcutHolder = atom({ plugin: 'unclutter', key: 'shortcutHolder' } as const, false)
23const shortcutHolderId = atom({ plugin: 'unclutter', key: 'shortcutHolderId' } as const, null)
24
25const clip = (text: string, max = MAX_CHARS): string =>
26 text.length > max ? `${text.slice(0, max)}\n... (${text.length - max} more characters)` : text
27
28const stringify = (value: unknown): string => {
29 if (value === undefined || value === null) return ''
30 if (typeof value === 'string') return value
31 try {
32 return JSON.stringify(value, null, 2)
33 } catch {
34 return String(value)
35 }
36}
37
38const textOf = (output: unknown): string => {
39 if (typeof output === 'string') return output
40 if (output !== null && typeof output === 'object') {
41 const record = output as Record<string, unknown>
42 if (Array.isArray(record.content)) {
43 const texts = record.content
44 .map(block => (block as { text?: unknown }).text)
45 .filter((text): text is string => typeof text === 'string')
46 if (texts.length > 0) return texts.join('\n')
47 }
48 if (typeof record.stdout === 'string' || typeof record.stderr === 'string') {
49 return [record.stdout, record.stderr].filter(Boolean).join('\n')
50 }
51 }
52 return stringify(output)
53}
54
55const parseNestedJson = (value: unknown): unknown => {
56 if (typeof value === 'string') {
57 const trimmed = value.trim()
58 if (!/^[[{]/.test(trimmed)) return value
59 try {
60 return parseNestedJson(JSON.parse(trimmed))
61 } catch {
62 return value
63 }
64 }
65 if (Array.isArray(value)) return value.map(parseNestedJson)
66 if (value !== null && typeof value === 'object') {
67 return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, parseNestedJson(inner)]))
68 }
69 return value
70}
71
72type CodeBlock = { label?: string; source: string; language?: string; path?: string; format?: 'diff' }
73
74type FileEdit = { old_string: string; new_string: string }
75
76const linesOf = (text: string): string[] => (text === '' ? [] : text.split('\n'))
77
78const unifiedDiff = ({ old_string, new_string }: FileEdit): string => {
79 const removed = linesOf(old_string)
80 const added = linesOf(new_string)
81 return [
82 `@@ -1,${removed.length} +1,${added.length} @@`,
83 ...removed.map(line => `-${line}`),
84 ...added.map(line => `+${line}`),
85 ].join('\n')
86}
87
88const isFileEdit = (value: unknown): value is FileEdit =>
89 value !== null &&
90 typeof value === 'object' &&
91 typeof (value as FileEdit).old_string === 'string' &&
92 typeof (value as FileEdit).new_string === 'string'
93
94const fileEditsOf = (input: Record<string, unknown>): FileEdit[] => {
95 if (isFileEdit(input)) return [input]
96 return Array.isArray(input.edits) ? input.edits.filter(isFileEdit) : []
97}
98
99const writtenContentOf = (input: Record<string, unknown>): string | undefined => {
100 if (typeof input.content === 'string') return input.content
101 if (typeof input.new_source === 'string') return input.new_source
102 return undefined
103}
104
105const filePathOf = (input: Record<string, unknown>): string | undefined => {
106 const path = input.file_path ?? input.notebook_path
107 return typeof path === 'string' ? path : undefined
108}
109
110const WRITE_KEYS = ['old_string', 'new_string', 'edits', 'content', 'new_source']
111
112const outputBlock = (tool: string, output: string, input: Record<string, unknown>): CodeBlock => {
113 const parsed = parseNestedJson(output)
114 if (typeof parsed !== 'string') return { source: JSON.stringify(parsed, null, 2), language: 'json' }
115 if (tool === 'Read' && typeof input.file_path === 'string') return { source: output, path: input.file_path }
116 return { source: output }
117}
118
119const inputBlocks = (tool: string, input: Record<string, unknown>): CodeBlock[] => {
120 const blocks: CodeBlock[] = []
121 const rest: Record<string, unknown> = {}
122 const path = filePathOf(input)
123 const edits = fileEditsOf(input)
124 const writtenContent = writtenContentOf(input)
125 edits.forEach((edit, index) =>
126 blocks.push({
127 label: edits.length > 1 ? `${path ?? 'edit'} (${index + 1}/${edits.length})` : path,
128 source: unifiedDiff(edit),
129 path,
130 format: 'diff',
131 }),
132 )
133 if (edits.length === 0 && writtenContent !== undefined) {
134 blocks.push({ label: path, source: writtenContent, path, language: path ? undefined : 'python' })
135 }
136 const isWrite = blocks.length > 0
137 for (const [key, value] of Object.entries(input)) {
138 if (isWrite && (WRITE_KEYS.includes(key) || value === path)) continue
139 if (tool === 'Bash' && key === 'command' && typeof value === 'string') {
140 blocks.push({ label: key, source: value, language: 'bash' })
141 } else if (SQL_KEYS.includes(key) && typeof value === 'string') {
142 blocks.push({ label: key, source: value, language: 'sql' })
143 } else if (key !== 'description') {
144 rest[key] = value
145 }
146 }
147 if (Object.keys(rest).length > 0) {
148 blocks.push({
149 label: blocks.length > 0 ? 'other' : undefined,
150 source: JSON.stringify(parseNestedJson(rest), null, 2),
151 language: 'json',
152 })
153 }
154 return blocks
155}
156
157const parseInput = (input: string): Record<string, unknown> => {
158 try {
159 const parsed: unknown = JSON.parse(input)
160 return parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {}
161 } catch {
162 return {}
163 }
164}
165
166const plural = (count: number, noun: string): string => `${count} ${noun}${count === 1 ? '' : 's'}`
167
168const summarize = (text: string, input?: unknown): string => {
169 const record = input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {}
170 const edits = fileEditsOf(record)
171 if (edits.length > 0) {
172 const added = edits.reduce((sum, edit) => sum + linesOf(edit.new_string).length, 0)
173 const removed = edits.reduce((sum, edit) => sum + linesOf(edit.old_string).length, 0)
174 return `+${added} -${removed} lines`
175 }
176 const writtenContent = writtenContentOf(record)
177 if (writtenContent !== undefined) return `wrote ${plural(linesOf(writtenContent).length, 'line')}`
178 const lines = linesOf(text).length
179 return lines === 0 ? 'no output' : plural(lines, 'line')
180}
181
182const displayToolName = (tool: string): string => {
183 const [, server, name] = tool.match(/^mcp__(.+?)__(.+)$/) ?? []
184 return server && name ? `${server} - ${name} (MCP)` : tool
185}
186
187const argumentsPreview = (input: unknown): string => {
188 if (input === null || typeof input !== 'object') return stringify(input)
189 return Object.entries(input as Record<string, unknown>)
190 .filter(([key]) => !WRITE_KEYS.includes(key))
191 .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
192 .join(', ')
193}
194
195const toolInput = (e: Record<string, unknown>): Record<string, unknown> =>
196 Object.fromEntries(
197 Object.entries(e).filter(([key]) => key !== 'tool' && key !== 'tool_use_id' && !ENVELOPE_KEYS.includes(key)),
198 )
199
200const withoutCodeBlocks = (markdown: string): string =>
201 markdown
202 .replace(/^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1[`~]*[ \t]*$/gm, '')
203 .replace(/\n{3,}/g, '\n\n')
204 .trim()
205
206const goalFrom = (messages: readonly SessionMessage[], toolUseId: string, input: Record<string, unknown>): string => {
207 if (typeof input.description === 'string' && input.description.trim()) return input.description.trim()
208
209 const ownIndex = messages.findIndex(message => message.toolUses.some(use => use.tool_use_id === toolUseId))
210 for (let index = ownIndex === -1 ? messages.length - 1 : ownIndex; index >= 0; index--) {
211 const message = messages[index]
212 const prose = message === undefined ? '' : withoutCodeBlocks(message.text)
213 if (message === undefined || !prose) continue
214 const isPrompt = message.role === 'user' && (message.toolResults?.length ?? 0) === 0
215 if (message.role === 'assistant' || isPrompt) return clip(prose, MAX_GOAL_CHARS)
216 }
217 return ''
218}
219
220const goalOf = async ($: EngineInterface, toolUseId: string, input: Record<string, unknown>): Promise<string> =>
221 goalFrom(await $.session.messages().catch(() => []), toolUseId, input)
222
223const moveShortcutHolder = async ($: EngineInterface, id: string): Promise<void> => {
224 const previous = await read($, shortcutHolderId)
225 if (previous === id) return
226 if (previous !== null) await update($, memberOf(shortcutHolder, { requestId: previous }), () => false)
227 await update($, memberOf(shortcutHolder, { requestId: id }), () => true)
228 await update($, shortcutHolderId, () => id)
229}
230
231const showDetail = async ($: EngineInterface, detail: ToolDetail): Promise<void> => {
232 const previous = await read($, selected)
233 if (previous !== null && previous.id !== detail.id) {
234 await update($, memberOf(highlighted, { requestId: previous.id }), () => false)
235 }
236 await update($, memberOf(highlighted, { requestId: detail.id }), () => true)
237 await update($, selected, () => detail)
238 await moveShortcutHolder($, detail.id)
239}
240
241const stepSelection = async ($: EngineInterface, direction: 1 | -1, expandedTools: ReadonlySet<string>): Promise<void> => {
242 const messages = await $.session.messages().catch(() => [])
243 const calls = messages.flatMap(message => message.toolUses).filter(call => !expandedTools.has(call.tool))
244 if (calls.length === 0) return
245
246 const current = await read($, selected)
247 const currentIndex = calls.findIndex(call => call.tool_use_id === current?.id)
248 const nextIndex =
249 currentIndex === -1
250 ? direction === 1 ? 0 : calls.length - 1
251 : Math.min(calls.length - 1, Math.max(0, currentIndex + direction))
252 const call = calls[nextIndex]
253 if (call === undefined || call.tool_use_id === current?.id) return
254
255 const id = call.tool_use_id
256 const recordedOutput = await read($, memberOf(outputs, { requestId: id }))
257 const hasResult = call.text !== undefined || call.result !== undefined
258 await showDetail($, {
259 id,
260 tool: call.tool,
261 goal: goalFrom(messages, id, call.input),
262 input: clip(stringify(call.input)),
263 output: recordedOutput || clip(call.text ?? textOf(call.result)),
264 isError: call.isError === true,
265 isRunning: !hasResult && !recordedOutput,
266 })
267 await $.ui.scroll({ to: { requestId: id }, block: 'center' }).catch(() => undefined)
268}
269
270const isFollowing = async ($: EngineInterface, followByDefault: boolean): Promise<boolean> =>
271 (await read($, followOverride)) ?? followByDefault
272
273const highlightBackgroundOf = async (
274 $: EngineInterface,
275 e: { requestId: string },
276 configuredColor: string,
277): Promise<string | undefined> => {
278 if (!(await read($, memberOf(highlighted, e)))) return undefined
279 if (configuredColor && configuredColor !== 'auto') return configuredColor
280 const rows = await $.config.list().catch(() => [])
281 const theme = String(rows.find(row => row.key === 'theme')?.value ?? '')
282 return theme.includes('light') ? HIGHLIGHT_BACKGROUNDS.light : HIGHLIGHT_BACKGROUNDS.dark
283}
284
285type RowState = { background: string | undefined; isShortcutHolder: boolean }
286
287const rowStateOf = async ($: EngineInterface, e: { requestId: string }, configuredColor: string): Promise<RowState> => ({
288 background: await highlightBackgroundOf($, e, configuredColor),
289 isShortcutHolder: await read($, memberOf(shortcutHolder, e)),
290})
291
292const collapsedResult = (
293 $: EngineInterface,
294 e: Parameters<EngineInterface['ui']['resolve']>[0] & { requestId: string },
295 call: { tool: string; output?: unknown; isErrored: boolean; input?: unknown },
296 { background, isShortcutHolder }: RowState,
297) => {
298 const { Box, Button, Text } = $.ui.resolve(e)
299 const text = textOf(call.output)
300 const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? ''
301 const isHighlighted = background !== undefined
302
303 const open = async () => {
304 const recordedInput = await read($, memberOf(inputs, e))
305 const recordedOutput = await read($, memberOf(outputs, e))
306 const detail: ToolDetail = {
307 id: e.requestId,
308 tool: call.tool,
309 goal: await read($, memberOf(goals, e)),
310 input: recordedInput || clip(stringify(call.input)),
311 output: recordedOutput || clip(text),
312 isError: call.isErrored,
313 isRunning: false,
314 }
315 await showDetail($, detail)
316 await $.ui.open({ id: PANE, title: PANE_TITLE, ...(isShortcutHolder ? { focus: true as const } : {}) })
317 }
318
319 return (
320 <Box backgroundColor={background}>
321 {isHighlighted ? <Text color="suggestion">{'▎ ⎿ '}</Text> : <Text dimColor>{' ⎿ '}</Text>}
322 {call.isErrored ? (
323 <Text color="red" wrap="truncate-end">
324 error: {firstLine}{' '}
325 </Text>
326 ) : (
327 <Text dimColor>{summarize(text, call.input)} </Text>
328 )}
329 <Button
330 key="details"
331 label="details"
332 plain
333 dimColor={!isHighlighted}
334 onPress={open}
335 {...(isShortcutHolder ? { action: OPEN_PANE_ACTION } : {})}
336 />
337 </Box>
338 )
339}
340
341export const register: Register = (on, options) => {
342 const followByDefault = options.followLatest !== false
343 const highlightColor = String(options.highlightColor ?? 'auto').trim()
344 const expandedTools = new Set(
345 String(options.expandedTools ?? '')
346 .split(',')
347 .map(name => name.trim())
348 .filter(Boolean),
349 )
350
351 on('session.start', async ($, e, next) => {
352 await $.command.register({
353 name: FOLLOW_COMMAND,
354 description: 'Toggle whether the tool call pane follows the latest tool call',
355 })
356 return next(e)
357 })
358
359 on('command.run', { command: FOLLOW_COMMAND }, async $ => {
360 const isNowFollowing = !(await isFollowing($, followByDefault))
361 await update($, followOverride, () => isNowFollowing)
362 return { text: `Tool call pane ${isNowFollowing ? 'follows the latest call' : 'stays on the call it shows'}.` }
363 })
364
365 on('tool.call', async ($, e, next) => {
366 const id = e.tool_use_id
367 const input = toolInput(e as unknown as Record<string, unknown>)
368 const inputText = clip(stringify(input))
369 const goal = await goalOf($, id, input)
370 await update($, memberOf(inputs, { requestId: id }), () => inputText)
371 await update($, memberOf(goals, { requestId: id }), () => goal)
372
373 const isTracked = e.agentId === undefined && (await isFollowing($, followByDefault))
374 if (isTracked) {
375 await showDetail($, {
376 id,
377 tool: e.tool,
378 goal,
379 input: inputText,
380 output: '',
381 isError: false,
382 isRunning: true,
383 })
384 }
385
386 const ran = await next(e)
387 const output = clip(ran.text ?? ran.deny ?? '')
388 const isError = ran.isError === true || ran.deny !== undefined
389 await update($, memberOf(outputs, { requestId: id }), () => output)
390 await update($, selected, current =>
391 current?.id === id ? { ...current, output, isError, isRunning: false } : current,
392 )
393 const isCollapsedOnMainThread = e.agentId === undefined && !expandedTools.has(e.tool)
394 if (isCollapsedOnMainThread && (await read($, selected)) === null) await moveShortcutHolder($, id)
395 return ran
396 }).catch(($, e, next) => next(e))
397
398 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
399 const isCollapsible = !e.props.isRunning && e.props.output !== undefined && !expandedTools.has(e.props.tool)
400 if (!isCollapsible) return next(e)
401
402 const { Box, Text } = $.ui.resolve(e)
403 const color = e.props.isErrored ? 'red' : 'green'
404 const rowState = await rowStateOf($, e, highlightColor)
405
406 return (
407 <Box flexDirection="column" backgroundColor={rowState.background}>
408 <Text wrap="truncate-end">
409 <Text color={color}>⏺ </Text>
410 <Text bold>{displayToolName(e.props.tool)}</Text>
411 <Text dimColor>({argumentsPreview(e.props.input)})</Text>
412 </Text>
413 {collapsedResult($, e, e.props, rowState)}
414 </Box>
415 )
416 })
417
418 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
419 if (expandedTools.has(e.props.tool)) return next(e)
420 return collapsedResult($, e, e.props, await rowStateOf($, e, highlightColor))
421 })
422
423 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
424 const { Box, Button, Code, Text } = $.ui.resolve(e)
425 const detail = await read($, selected)
426 const isFollowingNow = await isFollowing($, followByDefault)
427
428 const header = (
429 <Box>
430 <Button
431 key="follow"
432 hotkey="f"
433 plain
434 dimColor
435 label={isFollowingNow ? 'following latest (f)' : 'pinned (f)'}
436 onPress={() => update($, followOverride, override => !(override ?? followByDefault))}
437 />
438 <Text> </Text>
439 <Button key="previous" hotkey="k" plain dimColor label="prev (k)" onPress={() => stepSelection($, -1, expandedTools)} />
440 <Text> </Text>
441 <Button key="next" hotkey="j" plain dimColor label="next (j)" onPress={() => stepSelection($, 1, expandedTools)} />
442 <Text> </Text>
443 <Button key="close" label="close" plain dimColor role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
444 </Box>
445 )
446
447 if (detail === null) {
448 return (
449 <Box flexDirection="column">
450 {header}
451 <Text dimColor>Press "details" on a tool call to see it here.</Text>
452 </Box>
453 )
454 }
455
456 const input = parseInput(detail.input)
457 const code = (block: CodeBlock) => (
458 <Box flexDirection="column">
459 {block.label !== undefined && <Text dimColor>{block.label}</Text>}
460 <Code source={block.source} language={block.language} path={block.path} format={block.format} />
461 </Box>
462 )
463
464 return (
465 <Box flexDirection="column">
466 {header}
467 <Text> </Text>
468 <Text bold>{displayToolName(detail.tool)}</Text>
469 <Text> </Text>
470 <Text bold dimColor>Goal</Text>
471 <Text>{detail.goal || '(none recorded)'}</Text>
472 <Text> </Text>
473 <Text bold dimColor>Input</Text>
474 {detail.input ? inputBlocks(detail.tool, input).map(code) : <Text dimColor>(not recorded)</Text>}
475 <Text> </Text>
476 <Text bold dimColor>{detail.isError ? 'Error' : 'Output'}</Text>
477 {detail.isRunning ? (
478 <Text dimColor>running...</Text>
479 ) : detail.isError ? (
480 <Text color="red">{detail.output}</Text>
481 ) : detail.output ? (
482 code(outputBlock(detail.tool, detail.output, input))
483 ) : (
484 <Text dimColor>(empty)</Text>
485 )}
486 </Box>
487 )
488 })
489}
490types/index.d.ts 25 lines1export type ToolDetail = {
2 id: string
3 tool: string
4 goal: string
5 input: string
6 output: string
7 isError: boolean
8 isRunning: boolean
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'unclutter': {
14 selected: ToolDetail | null
15 followOverride: boolean | null
16 inputs: StateFamily<string>
17 outputs: StateFamily<string>
18 goals: StateFamily<string>
19 highlighted: StateFamily<boolean>
20 shortcutHolder: StateFamily<boolean>
21 shortcutHolderId: string | null
22 }
23 }
24}
25