Collapses each tool call to one clickable line in a color you pick, showing the model's own description of the call where it has one.

<img src=".claude/skills/collapse-tools/.claude-plugin/icon.png" alt="Collapse Tools icon" width="120" height="120"> <h1>Collapse Tools for Claude Code</h1>
<a href="#installation">Install</a> · <a href="#usage">Usage</a> · <a href="#how-it-works">How it works</a>
Collapse Tools is a Claude Code plugin that draws each tool-call row in the transcript as one line, [+] Name arg, to save screen rows. Click a row to expand it into a single [-] header followed by the normal output, or run /collapse-tools to toggle all calls at once. A toast at session start (once) tells you how. The tool name of a finished call is white by default, and you can change that color.
Collapsed rows: done, running and error (the [+] is dim, the argument is dim, a problem name is bold):
[+] Bash npm run check
[+] github:create_issue Fix the login redirect running
[+] Edit /src/app.ts error
With row summaries on (the default), a call whose input carries a description shows that description in place of the argument:
[+] Bash Show git status head and last commit
[+] Agent Audit the auth flow
[+] Read /src/auth.ts
One expanded row (the header replaces the engine's row; the result is drawn as usual below it):
[-] Bash npm run check timeout: 120000, description: Run the full gate
[+] Name arg. The name of a done call is white (not bold) by default and you can change it; a running call is yellow, an error red and an interrupted call gray, all three bold. A status word (running, error, interrupted) is right-aligned at the end of the row, in the same color; a done call shows none./collapse-tools summary on|off or the summaries option. The expanded header always keeps the raw input.mcp__server__tool shows as server:tool.[+] marks a row you can click. Once per session, on start, a toast says "click a [+] row to expand, /collapse-tools to toggle all".[-] header with the full input; clicking the header collapses it again./collapse-tools command. Switches the default for all calls and clears per-call toggles. The choice is saved across sessions./collapse-tools color <name|#hex|reset> or the doneColor option.Read 3 files are left to the engine.Install from the parent marketplace:
/plugin marketplace add 0xnicholasy/claude-mods
/plugin install collapse-tools@claude-mods
The same works from the shell as claude plugin marketplace add 0xnicholasy/claude-mods and claude plugin install collapse-tools@claude-mods. Add -s project to install for one project only (scopes: user, project, local; default user).
To run from a checkout instead:
git clone https://github.com/0xnicholasy/claude-mod-collapse-tools.git
cd claude-mod-collapse-tools
claude --plugin-dir .claude/skills/collapse-tools
Run it from the repo root, or pass the absolute path to .claude/skills/collapse-tools. An installed plugin takes precedence over a local copy with the same name, so uninstall it while developing. If /collapse-tools is not offered, run claude plugin list to check that the plugin is loaded and enabled.
Update (restart required):
claude plugin marketplace update claude-mods
claude plugin update collapse-tools@claude-mods
Uninstall with claude plugin uninstall collapse-tools@claude-mods.
Tool calls are collapsed by default. These controls change that:
| Control | Effect | ||
|---|---|---|---|
| `/collapse-tools color <name\ | #hex\ | reset>` | Sets the color of the tool name of finished calls and saves it for future sessions. reset clears the saved color. An unknown color is rejected with a message; any other argument prints a usage line. |
| `/collapse-tools summary on\ | off` | Turns row summaries on or off and saves the choice for future sessions. Any other argument prints a usage line. | |
/collapse-tools | Flips the default for all calls (collapsed to expanded, or back) and clears every per-call toggle. Replies "Tool calls collapsed to one line." or "Tool calls expanded." The new default is saved. | ||
| Click a line | Toggles that one call against the current default. Not saved across sessions. |
Completed-call color (marks a call that completed successfully; running is yellow, error red, interrupted gray): <name> is black, red, green, yellow, blue, magenta, cyan, white, gray (or grey), claude, or a ...Bright variant of the basic names; #rrggbb also works. The color in effect is the one saved by /collapse-tools color, else the doneColor plugin option (Completed call color; default white), else white. An invalid option value falls back to the default.
Row summaries: the setting in effect is the one saved by /collapse-tools summary on|off, else the summaries plugin option (Row summaries; default on), else on. When on, a collapsed row for Bash, Agent or Monitor shows [+] Name <description> in place of the argument, where <description> is the call's own description input, cleaned of quotes, control characters and trailing punctuation and cut to 60 characters. These are the built-in tools whose description field is documented as a short statement of what the call is for; MCP tools and other tools are excluded because their description means something else. A call with no description, or an empty one, shows the raw argument. Summaries show only on collapsed rows (/collapse-tools toggles all); an expanded row shows the raw input. With summaries off, rows show the raw argument.
A per-call toggle beats the default until the next /collapse-tools. The status is interrupted if the call was aborted, otherwise error if it failed, otherwise running while it runs, otherwise done.
The collapsed argument is the first non-empty string among the call's command, file_path, path, pattern, description, title and op fields. If none is present, the first string field of the input is used. Whitespace is collapsed and the argument is cut with ... so the row fits the terminal width less 4 columns (100 columns when the width is unknown).
The expanded header shows that same field in full (whitespace collapsed), then the input's other fields as a dim key: value, key: value list. The header after the tool name is capped at 6 times the terminal width in characters (about 6 lines) and ends with ... where it is cut. A non-done status word follows the header.
Tools that keep the engine's row: for Agent, Task, AskUserQuestion, TodoWrite and ExitPlanMode the expanded view is the header followed by the engine's own row, because that row may carry more than Name(arg). Every other tool, including Edit and Write (their diff is part of the tool's output, which the result block draws), shows the header alone.
The plugin registers five hooks in .claude/skills/collapse-tools/hooks/register.tsx. The pure logic (argument picking, status, row building, expand rule) is in hooks/summary.ts; the summary logic (the allowlist, describe, text cleaning, setting precedence) is in hooks/summarize.ts.
| Hook | What it does | |
|---|---|---|
session.start | Loads the saved default, done color and summaries setting from the plugin store, registers the /collapse-tools command and, once per session, shows the click-hint toast. | |
turn.start | Reloads the saved default and, when unset, the done color and the summaries setting. /clear resets the atoms and no session.start follows, so this restores the choice on the next turn. | |
command.run (collapse-tools) | With color ..., sets or resets the done color; with `summary on\ | off`, sets the summaries setting. Otherwise flips the default, increments the epoch (which invalidates per-call toggles), saves the default to the store and returns the reply text. |
ui.render (ToolUse) | Replaces the call row with a button line. Collapsed it is the [+] row, showing the call's description when summaries are on and the tool has one; expanded it is the [-] header, followed by the engine's own row for the tools listed above. | |
ui.render (ToolResult) | Draws the engine's result when the call is expanded. Otherwise returns a Box with display="none". |
State atoms (declared in types/index.d.ts under collapse-tools):
| Atom | Type | Purpose |
|---|---|---|
collapsed | boolean | The default for all calls. Starts true. |
epoch | number | Counter bumped by /collapse-tools. A toggle written under an older epoch counts as no toggle. |
hinted | boolean | True once the startup toast was shown. Starts false. |
doneColorOverride | string or null | The color saved by /collapse-tools color; null defers to the doneColor option. |
summariesOn | boolean or null | The setting saved by /collapse-tools summary; null defers to the summaries option. |
overrides | family of { epoch, open } | Per-call toggle, keyed by tool_use_id. A click redraws only that call. |
collapsed, the done color and the summaries setting are persisted, under the plugin store keys collapsed, doneColor and summaries. If a store read or write fails, the error goes to the debug log and the in-session value is kept. Render hooks never write state; writes happen in the click handler and in command.run.
description the model already wrote in the call's input, read at draw time.collapsed, doneColor, summaries) when you run /collapse-tools, /collapse-tools color or /collapse-tools summary.ToolUse and ToolResult components and delivers clicks. Only the terminal surface is assumed; other surfaces are untested.npm install
npm run check
npm run check runs validate (claude plugin validate), typecheck (tsc -p tsconfig.json) and test (claude plugin test). Validate and test need the claude CLI and run locally only; CI runs typecheck.
.claude/skills/collapse-tools/ (manifest .claude-plugin/plugin.json, hooks in hooks/, state contract in types/index.d.ts). Bump the version in plugin.json for releases.$.state atoms and not in module variables.rtk proxy npm run check./collapse-tools.Agent, Task, AskUserQuestion, TodoWrite and ExitPlanMode keep the engine's row under the header when expanded.success, error, warning, inactive) read as green, red, yellow and gray in every theme.display="none", the only draw-nothing option the API documents (a hook must return an element; it cannot return null). A margin the engine puts around each transcript message cannot be changed from a plugin, so some spacing between consecutive collapsed rows may remain.description get a summary: Bash, Agent and Monitor. Every other row shows the raw argument.[-]) to see the real input.MIT. See LICENSE.
hooks/register.tsx 217 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import { DONE_COLOR_STORE_KEY, resolveDoneColor, validColor } from './accent'
4import { HINT_TEXT, collapsedSegments, expandedSegments, isExpanded, keepsEngineRow } from './summary'
5import { SUMMARIES_STORE_KEY, describe, resolveSummaries } from './summarize'
6
7const collapsed = atom({ plugin: 'collapse-tools', key: 'collapsed' } as const, true as boolean)
8const epoch = atom({ plugin: 'collapse-tools', key: 'epoch' } as const, 0)
9// True once the startup hint toast was shown, so it appears once per session.
10const hinted = atom({ plugin: 'collapse-tools', key: 'hinted' } as const, false as boolean)
11// The color set by `/collapse-tools color`, loaded from the plugin store at session start; null
12// defers to the plugin option `doneColor`.
13const doneColorOverride = atom({ plugin: 'collapse-tools', key: 'doneColorOverride' } as const, null as string | null)
14// The setting saved by `/collapse-tools summary on|off`, loaded from the plugin store; null defers to
15// the plugin option `summaries`.
16const summariesOn = atom({ plugin: 'collapse-tools', key: 'summariesOn' } as const, null as boolean | null)
17// Per-call override family: each member is addressed by tool_use_id, so a click redraws only that call.
18const overrideRef = { plugin: 'collapse-tools', key: 'overrides' } as const
19
20type Override = { epoch: number; open: boolean }
21
22// A member written under an older epoch was reset by /collapse-tools and counts as no override.
23const openOf = (entry: Override | undefined, current: number): boolean | undefined =>
24 entry !== undefined && entry.epoch === current ? entry.open : undefined
25
26const STORE_KEY = 'collapsed'
27const USAGE =
28 'Usage: /collapse-tools (toggle all) | /collapse-tools color <name|#hex|reset> | /collapse-tools summary on|off'
29const SUMMARY_USAGE = 'Usage: /collapse-tools summary on|off'
30const unknownColorText = (value: string): string =>
31 `Unknown color "${value}". Use a name (red, green, yellow, blue, magenta, cyan, white, gray, claude, or a ...Bright variant) or #rrggbb.`
32
33async function loadSavedColor($: EngineInterface): Promise<void> {
34 try {
35 const saved = validColor(await $.store.get(DONE_COLOR_STORE_KEY))
36 if (saved !== null) await update($, doneColorOverride, () => saved)
37 } catch (error) {
38 $.ui.log(`collapse-tools: color load failed ${String(error)}`, { to: 'debug' })
39 }
40}
41
42async function runColorCommand($: EngineInterface, value: string): Promise<{ text: string }> {
43 if (value.toLowerCase() === 'reset') {
44 await update($, doneColorOverride, () => null)
45 try {
46 await $.store.delete(DONE_COLOR_STORE_KEY)
47 } catch (error) {
48 $.ui.log(`collapse-tools: color store delete failed ${String(error)}`, { to: 'debug' })
49
50 return { text: 'Completed-call color reset for this session only; the saved color could not be cleared.' }
51 }
52
53 return { text: 'Completed-call color reset. The saved color is cleared for future sessions.' }
54 }
55 if (value === '') return { text: USAGE }
56 const color = validColor(value)
57 if (color === null) return { text: unknownColorText(value) }
58 await update($, doneColorOverride, () => color)
59 try {
60 await $.store.set(DONE_COLOR_STORE_KEY, color)
61 } catch (error) {
62 $.ui.log(`collapse-tools: color store write failed ${String(error)}`, { to: 'debug' })
63
64 return { text: `Completed-call color set to ${color} for this session only; it could not be saved.` }
65 }
66
67 return { text: `Completed-call color set to ${color}. Saved for future sessions.` }
68}
69
70async function loadSavedSummaries($: EngineInterface): Promise<void> {
71 try {
72 const saved = await $.store.get(SUMMARIES_STORE_KEY)
73 if (typeof saved === 'boolean') await update($, summariesOn, () => saved)
74 } catch (error) {
75 $.ui.log(`collapse-tools: summaries load failed ${String(error)}`, { to: 'debug' })
76 }
77}
78
79async function runSummaryCommand($: EngineInterface, value: string): Promise<{ text: string }> {
80 const word = value.toLowerCase()
81 if (word !== 'on' && word !== 'off') return { text: SUMMARY_USAGE }
82 const enabled = word === 'on'
83 await update($, summariesOn, () => enabled)
84 const label = enabled ? 'on' : 'off'
85 try {
86 await $.store.set(SUMMARIES_STORE_KEY, enabled)
87 } catch (error) {
88 $.ui.log(`collapse-tools: summaries store write failed ${String(error)}`, { to: 'debug' })
89
90 return { text: `Row summaries ${label} for this session only; the choice could not be saved.` }
91 }
92
93 return { text: `Row summaries ${label}. Saved for future sessions.` }
94}
95
96// Loads the saved global choice; a store error or a non-boolean keeps the default (collapsed).
97async function loadSaved($: EngineInterface): Promise<void> {
98 try {
99 const saved = await $.store.get(STORE_KEY)
100 if (typeof saved !== 'boolean') return
101 const current = await read($, collapsed)
102 if (saved !== current) await update($, collapsed, () => saved)
103 } catch (error) {
104 $.ui.log(`collapse-tools: store read failed ${String(error)}`, { to: 'debug' })
105 }
106}
107
108export const register: Register = (on, options) => {
109 // Effective color = saved `/collapse-tools color` (doneColorOverride) ?? plugin option ?? default.
110 const configColor = resolveDoneColor(null, options.doneColor)
111
112 on('session.start', async ($, e, next) => {
113 await loadSaved($)
114 await loadSavedColor($)
115 await loadSavedSummaries($)
116 await $.command.register({
117 name: 'collapse-tools',
118 description: 'Toggle between one-line and full tool-call rows, or set the done color',
119 argumentHint: 'color <name|#hex|reset> | summary on|off',
120 })
121 if (e.isInteractive && !(await read($, hinted))) {
122 await update($, hinted, () => true)
123 $.ui.toast(HINT_TEXT, { timeoutMs: 8000 })
124 }
125
126 return next(e)
127 })
128
129 // The atoms reset on /clear and no session.start follows, so reload the saved choice on the next turn.
130 on('turn.start', async ($, e, next) => {
131 await loadSaved($)
132 // Atoms reset on /clear without a session.start, so reload the color when it is unset.
133 if ((await read($, doneColorOverride)) === null) await loadSavedColor($)
134 if ((await read($, summariesOn)) === null) await loadSavedSummaries($)
135
136 return next(e)
137 })
138
139 on('command.run', { command: 'collapse-tools' }, async ($, e) => {
140 const raw = e.args.trim()
141 const word = raw.toLowerCase()
142 if (word === 'color' || word.startsWith('color ')) return runColorCommand($, raw.slice('color'.length).trim())
143 if (word === 'summary' || word.startsWith('summary ')) return runSummaryCommand($, raw.slice('summary'.length).trim())
144 if (raw !== '') return { text: USAGE }
145 const next = !(await read($, collapsed))
146 await update($, collapsed, () => next)
147 await update($, epoch, n => n + 1)
148 try {
149 await $.store.set(STORE_KEY, next)
150 } catch (error) {
151 $.ui.log(`collapse-tools: store write failed ${String(error)}`, { to: 'debug' })
152 }
153
154 return { text: next ? 'Tool calls collapsed to one line.' : 'Tool calls expanded.' }
155 })
156
157 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
158 const { Box, Text, Button } = $.ui.resolve(e)
159 const id = e.props.tool_use_id
160 const member = { ...overrideRef, id }
161 const [global, current, entry, override, summariesFlag] = await Promise.all([
162 read($, collapsed),
163 read($, epoch),
164 read($, member),
165 read($, doneColorOverride),
166 read($, summariesOn),
167 ])
168 const doneColor = override ?? configColor
169 const open = isExpanded(global, openOf(entry, current))
170 const toggle = async () => {
171 const [g, ep] = await Promise.all([read($, collapsed), read($, epoch)])
172 await update($, member, cur => ({ epoch: ep, open: !isExpanded(g, openOf(cur, ep)) }))
173 }
174 const call = e.props
175 const summary = resolveSummaries(summariesFlag, options.summaries) ? describe(call.tool, call.input) : null
176 const segments = open
177 ? expandedSegments(call, e.viewport?.columns, doneColor)
178 : collapsedSegments(
179 call,
180 e.viewport?.columns,
181 doneColor,
182 summary ?? undefined,
183 )
184 const row = (
185 <Button key={`collapse-tools:${id}`} plain onPress={toggle}>
186 {segments.map(segment => (
187 <Text color={segment.color} bold={segment.bold} dimColor={segment.dim}>
188 {segment.text}
189 </Text>
190 ))}
191 </Button>
192 )
193 // Expanded: the header replaces the engine's row. Tools whose own row may carry more keep it below.
194 if (!open || !keepsEngineRow(call.tool)) return row
195
196 return (
197 <Box flexDirection="column">
198 {row}
199 {await next(e)}
200 </Box>
201 )
202 })
203
204 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
205 const { Box } = $.ui.resolve(e)
206 const id = e.props.tool_use_id
207 const [global, current, entry] = await Promise.all([
208 read($, collapsed),
209 read($, epoch),
210 read($, { ...overrideRef, id }),
211 ])
212 if (isExpanded(global, openOf(entry, current))) return next(e)
213
214 return <Box display="none" />
215 })
216}
217hooks/accent.ts 22 lines1import { DEFAULT_DONE_COLOR } from './summary'
2
3const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/
4const BASE_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray', 'grey']
5const NAMED: Record<string, string> = Object.fromEntries([
6 ...BASE_NAMES.map(name => [name, name]),
7 ...BASE_NAMES.map(name => [`${name}bright`, `${name}Bright`]),
8 ['claude', 'claude'],
9])
10export const DONE_COLOR_STORE_KEY = 'doneColor'
11
12// `value` is unknown because it comes from the plugin option or the store, which are untyped.
13export const validColor = (value: unknown): string | null => {
14 if (typeof value !== 'string') return null
15 if (HEX_PATTERN.test(value)) return value
16
17 return Object.hasOwn(NAMED, value.toLowerCase()) ? (NAMED[value.toLowerCase()] ?? null) : null
18}
19
20export const resolveDoneColor = (saved: unknown, option: unknown): string =>
21 validColor(saved) ?? validColor(option) ?? DEFAULT_DONE_COLOR
22hooks/summary.ts 228 lines1export const DEFAULT_COLUMNS = 100
2// Columns kept free at the right of a collapsed row (the engine's gutter and a cursor cell).
3export const MARGIN = 4
4// Two spaces between the parts of a row.
5const GAP = 2
6// An arg shorter than this is not worth drawing beside the name.
7const MIN_ARG = 4
8// The expanded header may take about this many terminal lines of text.
9export const EXPANDED_LINES = 6
10// One extra `key: value` is cut to this many characters before the whole list is capped.
11const MAX_EXTRA_VALUE = 40
12
13export const MARKER_CLOSED = '[+]'
14export const MARKER_OPEN = '[-]'
15
16// Shown once per session at start, so the clickable rows are discoverable.
17export const HINT_TEXT = 'click a [+] row to expand, /collapse-tools to toggle all'
18
19// Input fields that name what a call works on, most telling first.
20const ARG_FIELDS = ['command', 'file_path', 'path', 'pattern', 'description', 'title', 'op'] as const
21
22// Tools whose engine ToolUse row may carry more than `Name(arg)`: the expanded view keeps the
23// header and then the engine's own row for them. Edit and Write are not here: their diff
24// (structuredPatch) is in the tool's output, which is drawn by the ToolResult.
25const KEEP_ENGINE_ROW: ReadonlySet<string> = new Set(['Agent', 'Task', 'AskUserQuestion', 'TodoWrite', 'ExitPlanMode'])
26
27export type CallStatus = 'running' | 'error' | 'interrupted' | 'done'
28
29// Theme keys, so the colors follow the person's theme: success green, error red, warning yellow,
30// inactive gray.
31export const DEFAULT_DONE_COLOR = 'white'
32
33export const STATUS_COLOR: Readonly<Record<CallStatus, string>> = {
34 done: DEFAULT_DONE_COLOR,
35 error: 'error',
36 running: 'warning',
37 interrupted: 'inactive',
38}
39
40export type CallView = {
41 tool: string
42 // unknown: mirrors RenderPropsOf.ToolUse.input, which the engine types declare as unknown.
43 input: unknown
44 isRunning: boolean
45 isErrored: boolean
46 isInterrupted: boolean
47}
48
49// One styled run of text in a row; the render hook maps it to a Text.
50export type Segment = {
51 text: string
52 color?: string
53 bold?: boolean
54 dim?: boolean
55}
56
57// `mcp__server__tool` reads `server:tool`; any other name is kept.
58export function formatToolName(tool: string): string {
59 if (!tool.startsWith('mcp__')) return tool
60 const rest = tool.slice('mcp__'.length)
61 const split = rest.indexOf('__')
62 if (split <= 0 || split + 2 >= rest.length) return tool
63 return `${rest.slice(0, split)}:${rest.slice(split + 2)}`
64}
65
66const squash = (text: string): string => text.replace(/\s+/g, ' ').trim()
67
68const length = (text: string): number => Array.from(text).length
69
70const segmentsLength = (segments: readonly Segment[]): number =>
71 segments.reduce((sum, segment) => sum + length(segment.text), 0)
72
73export const segmentsText = (segments: readonly Segment[]): string => segments.map(segment => segment.text).join('')
74
75export function truncate(text: string, max: number): string {
76 if (max <= 0) return ''
77 const chars = Array.from(text)
78 if (chars.length <= max) return text
79 if (max <= 3) return chars.slice(0, max).join('')
80 return `${chars.slice(0, max - 3).join('')}...`
81}
82
83function asRecord(input: unknown): Record<string, unknown> | null {
84 if (typeof input !== 'object' || input === null || Array.isArray(input)) return null
85 return input as Record<string, unknown>
86}
87
88type ArgField = { key: string; value: string }
89
90// The most telling string field of the input, whitespace collapsed, in full; null when none.
91function pickArgField(input: unknown): ArgField | null {
92 const record = asRecord(input)
93 if (record === null) return null
94 for (const key of ARG_FIELDS) {
95 const value = record[key]
96 if (typeof value === 'string' && squash(value) !== '') return { key, value: squash(value) }
97 }
98 for (const [key, value] of Object.entries(record)) {
99 if (typeof value === 'string' && squash(value) !== '') return { key, value: squash(value) }
100 }
101 return null
102}
103
104// The most telling string field of the input, whitespace collapsed; '' when none.
105export function pickArg(input: unknown): string {
106 return pickArgField(input)?.value ?? ''
107}
108
109// unknown: the values of a tool input are whatever JSON the model sent.
110function extraFields(input: unknown, skip: string | undefined): string {
111 const record = asRecord(input)
112 if (record === null) return ''
113 const parts: string[] = []
114 for (const [key, value] of Object.entries(record)) {
115 if (key === skip) continue
116 if (typeof value === 'string') {
117 const text = squash(value)
118 if (text !== '') parts.push(`${key}: ${truncate(text, MAX_EXTRA_VALUE)}`)
119 } else if (typeof value === 'number' || typeof value === 'boolean') {
120 parts.push(`${key}: ${String(value)}`)
121 }
122 }
123 return parts.join(', ')
124}
125
126// An abort wins over an error, an error over running.
127export function statusOf(call: Pick<CallView, 'isRunning' | 'isErrored' | 'isInterrupted'>): CallStatus {
128 if (call.isInterrupted) return 'interrupted'
129 if (call.isErrored) return 'error'
130 if (call.isRunning) return 'running'
131 return 'done'
132}
133
134// A finished call says nothing: its green name is the status.
135// Problems stay bold so they stand out; a finished call is plain.
136const isBold = (status: CallStatus): boolean => status !== 'done'
137
138const colorOf = (status: CallStatus, doneColor: string): string =>
139 status === 'done' ? doneColor : STATUS_COLOR[status]
140
141export const statusWord = (status: CallStatus): string => (status === 'done' ? '' : status)
142
143const validColumns = (columns: number | undefined): number =>
144 columns !== undefined && columns > 0 ? columns : DEFAULT_COLUMNS
145
146// Cuts the runs to `width` characters in total, ending with `...` where it cut.
147function clipSegments(segments: readonly Segment[], width: number): Segment[] {
148 if (segmentsLength(segments) <= width) return [...segments]
149 const clipped: Segment[] = []
150 let left = Math.max(width, 0)
151 for (const segment of segments) {
152 if (left <= 0) break
153 const size = length(segment.text)
154 if (size <= left) {
155 clipped.push(segment)
156 left -= size
157 continue
158 }
159 clipped.push({ ...segment, text: truncate(segment.text, left) })
160 break
161 }
162 return clipped
163}
164
165// `[+] Name arg status`: the marker dim, the name bold in the status color, the arg dim
166// and cut to fit, the status word right-aligned and only when the call is not done. A non-empty
167// `summary` (the model-written description) is drawn where the arg would be.
168export function collapsedSegments(
169 call: CallView,
170 columns: number | undefined,
171 doneColor: string = DEFAULT_DONE_COLOR,
172 summary?: string,
173): Segment[] {
174 const width = Math.max(validColumns(columns) - MARGIN, 1)
175 const status = statusOf(call)
176 const color = colorOf(status, doneColor)
177 const word = statusWord(status)
178 const segments: Segment[] = [
179 { text: `${MARKER_CLOSED} `, dim: true },
180 { text: formatToolName(call.tool), color, bold: isBold(status) },
181 ]
182 const wordRoom = word === '' ? 0 : GAP + length(word)
183 const argRoom = width - segmentsLength(segments) - GAP - wordRoom
184 const arg = summary !== undefined && squash(summary) !== '' ? squash(summary) : pickArg(call.input)
185 if (arg !== '' && argRoom >= MIN_ARG) segments.push({ text: ' '.repeat(GAP) + truncate(arg, argRoom), dim: true })
186 if (word !== '') {
187 const pad = Math.max(GAP, width - segmentsLength(segments) - length(word))
188 segments.push({ text: ' '.repeat(pad) + word, color })
189 }
190 return clipSegments(segments, width)
191}
192
193// `[-] Name full input key: value, ...`: the most telling field in full, then the other fields dim.
194// The text after the name is capped at about EXPANDED_LINES lines of the terminal width.
195export function expandedSegments(
196 call: CallView,
197 columns: number | undefined,
198 doneColor: string = DEFAULT_DONE_COLOR,
199): Segment[] {
200 const budget = validColumns(columns) * EXPANDED_LINES
201 const status = statusOf(call)
202 const color = colorOf(status, doneColor)
203 const word = statusWord(status)
204 const segments: Segment[] = [
205 { text: `${MARKER_OPEN} `, dim: true },
206 { text: formatToolName(call.tool), color, bold: isBold(status) },
207 ]
208 const field = pickArgField(call.input)
209 const extras = extraFields(call.input, field?.key)
210 // With extras to follow, keep room for a gap and the closing `...` after the main field.
211 const mainRoom = budget - segmentsLength(segments) - GAP - (extras === '' ? 0 : GAP + 3)
212 if (field !== null && mainRoom > 0) segments.push({ text: ' '.repeat(GAP) + truncate(field.value, mainRoom) })
213 if (extras !== '') {
214 const room = budget - segmentsLength(segments) - GAP
215 segments.push({ text: ' '.repeat(GAP) + (room >= 8 ? truncate(extras, room) : '...'), dim: true })
216 }
217 if (word !== '') segments.push({ text: ' '.repeat(GAP) + word, color })
218 return segments
219}
220
221// For these tools the expanded view also draws the engine's own ToolUse row under the header.
222export const keepsEngineRow = (tool: string): boolean => KEEP_ENGINE_ROW.has(tool)
223
224// A per-call toggle beats the global default.
225export function isExpanded(collapsed: boolean, override: boolean | undefined): boolean {
226 return override ?? !collapsed
227}
228hooks/summarize.ts 47 lines1export const SUMMARIES_STORE_KEY = 'summaries'
2
3export const MAX_SUMMARY = 60
4
5// Built-in tools whose input `description` field is documented as a short statement of what the call is
6// for (Bash: "Clear, concise description of what this command does"; Agent: "A short (3-5 word)
7// description of the task"; Monitor: "Short human-readable description of what you are monitoring").
8// Tools whose `description` is a task body or means something else (TaskCreate, TaskUpdate, MCP tools)
9// are left out.
10// 'Task' is the Agent tool's older name (matching KEEP_ENGINE_ROW in summary.ts).
11const DESCRIBED_TOOLS: ReadonlySet<string> = new Set(['Bash', 'Agent', 'Task', 'Monitor'])
12
13const TRAILING = /[\s.,;:!?'‘’]+$/
14
15const cut = (text: string, max: number): string => {
16 const chars = Array.from(text)
17 return chars.length <= max ? text : chars.slice(0, max).join('')
18}
19
20export function cleanSummary(text: string): string {
21 const flat = text
22 .replace(/\p{Cc}/gu, ' ')
23 .replace(/\p{Cf}/gu, '')
24 .replace(/["`“”]/g, '')
25 .replace(/\s+/g, ' ')
26 .trim()
27 .replace(/^['‘’]+/, '')
28 .replace(TRAILING, '')
29 return cut(flat, MAX_SUMMARY).replace(TRAILING, '')
30}
31
32// unknown: `saved` is an unvalidated store read and `option` is a plugin-option union, so both are narrowed here.
33export const resolveSummaries = (saved: unknown, option: unknown): boolean =>
34 typeof saved === 'boolean' ? saved : typeof option === 'boolean' ? option : true
35
36// The purpose line the model wrote for a call, or null when the tool is not on the allowlist or the
37// input carries no usable description.
38// unknown: the engine types a ToolUse input as unknown; it is narrowed to a plain object below.
39export function describe(tool: string, input: unknown): string | null {
40 if (!DESCRIBED_TOOLS.has(tool)) return null
41 if (typeof input !== 'object' || input === null || Array.isArray(input)) return null
42 const description = (input as Record<string, unknown>).description
43 if (typeof description !== 'string') return null
44 const clean = cleanSummary(description)
45 return clean === '' ? null : clean
46}
47types/index.d.ts 13 lines1declare module 'claude-code' {
2 interface PluginState {
3 'collapse-tools': {
4 collapsed: boolean
5 epoch: number
6 hinted: boolean
7 doneColorOverride: string | null
8 summariesOn: boolean | null
9 overrides: StateFamily<{ epoch: number; open: boolean }>
10 }
11 }
12}
13