SLOPSHOPPER

collapse-tools

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.

newrowscommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · collapse-tools
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ collapse-tools │ ⏺ Read(src/auth.ts) │ click a [+] row to expand, /collapse-tools │ ⎿ Read 6 lines │ to toggle all │ collapse-tools:undefined ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /collapse-tools ⎿ collapse-tools: Tool calls expanded. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
collapse-tools:undefined
README

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

CI Version License Stars

<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.

What it looks like

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

Features

  • One-line rows. Each call reads [+] 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.
  • Row summaries. A collapsed row shows the description the model wrote for the call (Bash, Agent and Monitor inputs carry one) in place of the raw argument. The plugin makes no model or network calls: the text is read from the call's own input. On by default; /collapse-tools summary on|off or the summaries option. The expanded header always keeps the raw input.
  • Readable names. An MCP tool mcp__server__tool shows as server:tool.
  • Click hint. [+] marks a row you can click. Once per session, on start, a toast says "click a [+] row to expand, /collapse-tools to toggle all".
  • Click to expand. Clicking a row expands that call into a single [-] 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.
  • Pickable completed-call color (the color of a call that completed successfully; running is yellow, error red, interrupted gray). /collapse-tools color <name|#hex|reset> or the doneColor option.
  • Collapsed results. The result block of a collapsed call is not drawn.
  • Tool groups such as Read 3 files are left to the engine.

Installation

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.

Usage

Tool calls are collapsed by default. These controls change that:

ControlEffect
`/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-toolsFlips 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 lineToggles 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.

How it works

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.

HookWhat it does
session.startLoads 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.startReloads 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):

AtomTypePurpose
collapsedbooleanThe default for all calls. Starts true.
epochnumberCounter bumped by /collapse-tools. A toggle written under an older epoch counts as no toggle.
hintedbooleanTrue once the startup toast was shown. Starts false.
doneColorOverridestring or nullThe color saved by /collapse-tools color; null defers to the doneColor option.
summariesOnboolean or nullThe setting saved by /collapse-tools summary; null defers to the summaries option.
overridesfamily 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.

Data and privacy

  • The plugin makes no model calls and no network calls. A row summary is the description the model already wrote in the call's input, read at draw time.
  • The only persisted data is the default choice, the done color and the summaries setting, stored under three plugin store keys (collapsed, doneColor, summaries) when you run /collapse-tools, /collapse-tools color or /collapse-tools summary.
  • Per-call toggles and the epoch live in session state and are not written to disk by the plugin.
  • Tool-call inputs are used only to build the one-line row on screen. The plugin does not store them or log them.
  • It reads no credentials or environment variables, and does not read or write files or run processes.

Requirements

  • Claude Code 2.1.295 or later, the version the API types were taken from, with plugin hooks.
  • A surface that draws the transcript with the engine's ToolUse and ToolResult components and delivers clicks. Only the terminal surface is assumed; other surfaces are untested.

Development

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.

  • Mod path: .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.
  • Hot reload: saving a file in the mod reloads the module in a running session. The default and per-call toggles survive because state lives in $.state atoms and not in module variables.
  • Under the RTK shell hook, run the check as rtk proxy npm run check.

Known limitations

  • Clicking a row needs a fullscreen terminal or the desktop app; elsewhere use /collapse-tools.
  • ctrl+o does not unfold these rows.
  • Some spacing may remain between consecutive collapsed rows (a transcript margin a plugin cannot change).
  • Agent, Task, AskUserQuestion, TodoWrite and ExitPlanMode keep the engine's row under the header when expanded.
  • Not verified in a live session: how the rows look, whether a click lands on the row on every surface, and whether the theme keys used for status colors (success, error, warning, inactive) read as green, red, yellow and gray in every theme.
  • A collapsed result is a Box with 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.
  • Which tools keep the engine's row is a judgment from the API types, not from a live check. A tool not listed whose engine row carries extra detail loses that detail when expanded.
  • Expanded input is shown on one line per field set (whitespace collapsed), so multi-line commands lose their line breaks.
  • Only tools whose input carries a description get a summary: Bash, Agent and Monitor. Every other row shows the raw argument.
  • A summary is the model's own words about the call, not a check of it, so it can differ from what the call does. Open the row ([-]) to see the real input.
  • Summaries show on collapsed rows only; the expanded header always shows the raw input.
  • Colors and the toast need a surface that draws them; only the terminal surface is assumed.

License

MIT. See LICENSE.

Source 5 files
hooks/register.tsx 217 lines
1import { 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}
217
hooks/accent.ts 22 lines
1import { 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
22
hooks/summary.ts 228 lines
1export 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}
228
hooks/summarize.ts 47 lines
1export 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}
47
types/index.d.ts 13 lines
1declare 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