SLOPSHOPPER

hide-diffs

Shows a one-line summary instead of the diff under Edit, Write, NotebookEdit and Bash calls (ctrl+q), and folds each shell command to one line (alt+q)

newbandrowsguardtoast
★ 1v1.2.0MITupdated 2026-10-06gixxy22/hide-diffs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hide-diffs
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 Diffs hidden (ctrl+q to show) · Commands folded (alt+q to show) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Diffs hidden (ctrl+q to show) · Commands folded (alt+q to show)
README

hide-diffs

Tired of long diffs filling your Claude Code screen? This mod swaps them for a short summary:

Changed src/api/index.ts:42 (+12 −3)

Press ctrl+q any time to show the full diffs again.

It also folds each shell command to one line, ● Bash(npm test -- --run src/api) ⎿ 24 lines. Press alt+q to show commands in full.

Without hide-diffs

An edit showing a 15-line diff

With hide-diffs

The same edit as a one-line summary

With compact mode (turn it on in settings)

Each edit on one line, with its counts after the file name

Without folded commands

Three shell commands, each with its output under it

With folded commands (alt+q switches them)

Each shell command on one line, with how many lines it printed or its error

What it does

  • Hides long diffs from edits, new files, notebooks and shell commands, and shows the file, line and lines changed instead.
  • Easy to spot. Every hidden diff starts with 🔶, so you can see at a glance where Claude changed something.
  • Keeps short diffs. Changes of 5 lines or fewer still show in full.
  • Never hides what you need. If Claude asks you to review an edit, you still see the whole diff.
  • Keeps count. A small line above the prompt shows what's changed this session: Diffs hidden (ctrl+q to show) · Commands folded (alt+q to show) · 14 files (+320 −85) this session
  • Folds shell commands. A finished Bash or PowerShell call takes one line: the command, cut to fit, then how many lines it printed (or its error in red, or what it changed in files). Press alt+q to show commands in full.
  • Compact mode. Turn it on in settings to put the line counts right on the edit's own line, ● Update(src/api/index.ts) 🔶 (+12 −3), so each change takes one line.
  • Remembers your choice. If you turn diffs back on, they stay on next time.

Install

Works on Windows, macOS and Linux.

1. Add the mod. In Claude Code, type these two commands:

/plugin marketplace add gixxy22/hide-diffs
/plugin install hide-diffs@gixxy22-plugins

2. Turn on the ctrl+q and alt+q shortcuts. Open your keybindings file:

  • Windows: C:\Users\<you>\.claude\keybindings.json
  • macOS / Linux: ~/.claude/keybindings.json

If the file doesn't exist, create it with this:

{
  "bindings": [
    { "context": "Global", "bindings": { "ctrl+q": "settings:sortByTokens", "alt+q": "settings:periodWeek" } }
  ]
}

If it already exists, just add the "ctrl+q": "settings:sortByTokens" and "alt+q": "settings:periodWeek" lines inside your Global bindings.

Why settings:sortByTokens and settings:periodWeek? A mod can only borrow a shortcut action that Claude Code isn't using at that moment. Those two are only used inside the /usage screen, so ctrl+q and alt+q always reach hide-diffs.

Prefer a different key? Use any key you like in place of ctrl+q.

3. Restart Claude Code. That's it.

Settings

Type /config and look for hide-diffs.

SettingDefaultWhat it does
Show small diffs5Diffs with this many changed lines or fewer still show in full. Set it to 0 to hide every diff.
Icon🔶Shown at the start of each hidden diff. Use any emoji or symbol, or leave it empty for none.
Compact modeoffPuts the line counts on an edit's own line, like ● Write(src/a.ts) 🔶 (+12 −0), and drops the summary line under it. Shell commands and notebooks keep their summary line.

Update

Run these in a terminal, then restart Claude Code:

claude plugin marketplace update gixxy22-plugins
claude plugin update hide-diffs@gixxy22-plugins

Uninstall

Run this in a terminal, then restart Claude Code:

claude plugin uninstall hide-diffs@gixxy22-plugins

License

MIT

Source 2 files
hooks/register.tsx 507 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CallChanges, CommandOutcome, CommandOutcomes, SessionTally } from '../types'
5
6type PatchHunk = { newStart?: number; lines: string[] }
7type FileChangeOutput = {
8  filePath: string
9  structuredPatch?: PatchHunk[]
10  type?: 'create' | 'update'
11  content?: string
12  gitDiff?: { additions: number; deletions: number }
13  staged?: boolean
14}
15type NotebookOutput = { notebook_path: string; cell_id?: string; old_source?: string; new_source: string }
16type BashEditDiff = { files: { filePath: string; hunks: PatchHunk[] }[]; moreFiles: number }
17type BashOutput = { bashEditDiff?: BashEditDiff }
18type ShellOutput = BashOutput & {
19  stdout?: string
20  stderr?: string
21  interrupted?: boolean
22  backgroundTaskId?: string
23  returnCodeInterpretation?: string
24}
25type Change = { added: number; removed: number }
26type TextElement = ReturnType<EngineInterface['ui']['resolve']>['Text']
27
28const DIFF_TOOLS = new Set(['Edit', 'Write', 'MultiEdit'])
29const TRACKED_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Bash'] as const
30// Tools whose runs the transcript folds into one line (`Made 3 edits +33 -5`).
31const FILE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
32
33// The chord bound to this action in ~/.claude/keybindings.json (ctrl+q) presses the toggle button.
34// A plugin can only borrow an engine action while no engine handler of it is mounted: the diff
35// panel holds app:toggleDiffNoiseFilter whenever the diff has test files, so ctrl+q never reached
36// the button. settings:sortByTokens is only handled inside the /usage dialog.
37const TOGGLE_ACTION = 'settings:sortByTokens'
38const STORE_KEY = 'isHiding'
39const isHiding = atom({ plugin: 'hide-diffs', key: 'isHiding' } as const, true)
40// The second chord (alt+q) folds shell commands; settings:periodWeek is another /usage-only action.
41const COMMANDS_ACTION = 'settings:periodWeek'
42const COMMANDS_STORE_KEY = 'isFoldingCommands'
43const isFoldingCommands = atom({ plugin: 'hide-diffs', key: 'isFoldingCommands' } as const, true)
44// Each finished shell call's one-line outcome: a finished row need not carry its result.
45const outcomes = atom({ plugin: 'hide-diffs', key: 'outcomes' } as const, {} as CommandOutcomes)
46const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
47const tally = atom({ plugin: 'hide-diffs', key: 'tally' } as const, { files: [], added: 0, removed: 0 } as SessionTally)
48// The project root when the session started; paths stay relative to it after `/cd` or a worktree move.
49const startRoot = atom({ plugin: 'hide-diffs', key: 'startRoot' } as const, null as string | null)
50// Each finished call's counts, for the compact line: a live call row need not carry its result.
51const changes = atom({ plugin: 'hide-diffs', key: 'changes' } as const, {} as CallChanges)
52
53function countChangedLines(hunks: PatchHunk[] = []): Change {
54  let added = 0
55  let removed = 0
56  for (const hunk of hunks) {
57    for (const line of hunk.lines) {
58      if (line.startsWith('+')) added += 1
59      else if (line.startsWith('-')) removed += 1
60    }
61  }
62  return { added, removed }
63}
64
65// The lines a finished Edit or Write added and removed; undefined for one held for
66// review, which leaves the file unchanged and whose diff the person needs to see.
67function fileChange(output: FileChangeOutput): Change | undefined {
68  if (output.staged) return undefined
69  // A new file's patch can be empty: count the lines it was written with.
70  if (output.type === 'create') {
71    return { added: output.content ? output.content.replace(/\n$/, '').split('\n').length : 0, removed: 0 }
72  }
73  // An empty patch means the diff timed out or the file was too large; git's counts stand in.
74  if (!output.structuredPatch?.length && output.gitDiff) {
75    return { added: output.gitDiff.additions, removed: output.gitDiff.deletions }
76  }
77  return countChangedLines(output.structuredPatch)
78}
79
80// The new-file line number of the first added or removed line.
81function firstChangedLine(hunks: PatchHunk[] = []) {
82  const hunk = hunks[0]
83  if (!hunk?.newStart) return undefined
84  let line = hunk.newStart
85  for (const text of hunk.lines) {
86    if (text.startsWith('+') || text.startsWith('-')) return line
87    line += 1
88  }
89  return undefined
90}
91
92// Lines only in one side of a cell, counted as a multiset: close enough for a summary.
93function countNotebookLines(output: NotebookOutput): Change {
94  const before = output.old_source ? output.old_source.split('\n') : []
95  const after = output.new_source ? output.new_source.split('\n') : []
96  const remaining = new Map<string, number>()
97  for (const line of before) remaining.set(line, (remaining.get(line) ?? 0) + 1)
98  let added = 0
99  for (const line of after) {
100    const left = remaining.get(line) ?? 0
101    if (left > 0) remaining.set(line, left - 1)
102    else added += 1
103  }
104  let removed = 0
105  for (const left of remaining.values()) removed += left
106  return { added, removed }
107}
108
109function relativePath(filePath: string, projectRoot: string) {
110  const path = filePath.replace(/\\/g, '/')
111  const root = projectRoot.replace(/\\/g, '/').replace(/\/$/, '') + '/'
112  // Windows paths ignore case; elsewhere a folder differing only in case is another folder.
113  const isWindows = /^[a-z]:\//i.test(root)
114  const isInside = isWindows ? path.toLowerCase().startsWith(root.toLowerCase()) : path.startsWith(root)
115  return isInside ? path.slice(root.length) : path
116}
117
118function summarize(change: Change) {
119  return `(+${change.added} −${change.removed})`
120}
121
122// `(+N −N)` with the added count green and the removed count red, in the theme's colours.
123function coloredSummary(Text: TextElement, change: Change) {
124  return (
125    <Text>
126      (<Text color="success">+{change.added}</Text> <Text color="error">−{change.removed}</Text>)
127    </Text>
128  )
129}
130
131// What a hidden diff's summary says: the file it changed and the lines it added and removed.
132// Undefined for anything drawn in full: another tool, a missing output, a review, a small diff.
133type HiddenChange = { what: string; change: Change }
134
135function describeChange(tool: string, output: unknown, root: string, isSmall: (change: Change) => boolean): HiddenChange | undefined {
136  if (tool === 'Bash') {
137    const changes = (output as BashOutput | undefined)?.bashEditDiff
138    if (!changes) return undefined
139    const fileCount = changes.files.length + changes.moreFiles
140    const change = countChangedLines(changes.files.flatMap(file => file.hunks))
141    if (fileCount === 0 || isSmall(change)) return undefined
142    return { what: `Changed ${fileCount} file${fileCount === 1 ? '' : 's'}`, change }
143  }
144
145  if (tool === 'NotebookEdit') {
146    const notebook = output as NotebookOutput | undefined
147    if (!notebook?.notebook_path) return undefined
148    const change = countNotebookLines(notebook)
149    if (isSmall(change)) return undefined
150    const cell = notebook.cell_id ? ` cell ${notebook.cell_id}` : ''
151    return { what: `Changed ${relativePath(notebook.notebook_path, root)}${cell}`, change }
152  }
153
154  if (!DIFF_TOOLS.has(tool)) return undefined
155  const file = output as FileChangeOutput | undefined
156  if (!file?.filePath) return undefined
157  const change = fileChange(file)
158  if (!change || isSmall(change)) return undefined
159  const verb = file.type === 'create' ? 'Created' : 'Changed'
160  const line = file.type === 'create' ? undefined : firstChangedLine(file.structuredPatch)
161  return { what: `${verb} ${relativePath(file.filePath, root)}${line ? `:${line}` : ''}`, change }
162}
163
164// How the engine lays out an Edit or Write row, which no API reports (as akilin/claude-plugins'
165// better-tool-rows measures it): a blank line, then `● Update(path)` as wide as the line,
166// up to GROUP_INDENT columns further in inside a group, a result line opening with ROW_GUTTER.
167const GROUP_INDENT = 6
168const ROW_GUTTER = '  ⎿  '
169// Tools whose row label is `● Name(file_path)`, so the counts can be laid just past it.
170const LABELED_TOOLS = new Set(['Edit', 'Write'])
171
172// The name the engine draws an Edit or Write row under.
173function rowName(tool: string, input: { old_string?: unknown }) {
174  return tool === 'Write' ? 'Write' : input.old_string === '' ? 'Create' : 'Update'
175}
176
177// The columns a string takes on a terminal: 2 for wide CJK characters and pictographs.
178function textWidth(text: string) {
179  let width = 0
180  for (const char of text) {
181    const c = char.codePointAt(0) ?? 0
182    if (/^[\p{Mn}\p{Me}​-‍︀-️]$/u.test(char)) continue
183    const isWide =
184      (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
185      (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60) || (c >= 0x1f300 && c <= 0x1faff)
186    width += isWide ? 2 : 1
187  }
188  return width
189}
190
191// Relative to `root` with its own separators, as the engine draws a path it is handed.
192function shortPath(path: string, root: string) {
193  const dir = /[\\/]$/.test(root) ? root : `${root}/`
194  const head = path.slice(0, dir.length)
195  const isWindows = /^([A-Za-z]:[\\/]|\\\\)/.test(root)
196  const fold = (text: string) => text.replace(/\\/g, '/').toLowerCase()
197  return (isWindows ? fold(head) === fold(dir) : head === dir) ? path.slice(dir.length) : path
198}
199
200// `text` cut to `width` columns, ending in `…` where it was cut.
201function fitWidth(text: string, width: number) {
202  if (textWidth(text) <= width) return text
203  let fitted = ''
204  for (const char of text) {
205    if (textWidth(fitted + char) > width - 1) break
206    fitted += char
207  }
208  return `${fitted}…`
209}
210
211function lineCount(text = '') {
212  const trimmed = text.replace(/\s+$/, '')
213  return trimmed ? trimmed.split('\n').length : 0
214}
215
216function plural(count: number, word: string) {
217  return `${count} ${word}${count === 1 ? '' : 's'}`
218}
219
220// What a folded command's line says of its result: `16 lines`, `no output`, or an
221// error's first line (`Exit code 1 (+3 lines)`).
222function commandOutcome(isErrored: boolean, output: unknown): CommandOutcome {
223  if (isErrored) {
224    const lines = (typeof output === 'string' ? output : '').split('\n').map(line => line.trim()).filter(Boolean)
225    const first = fitWidth((lines[0] ?? 'Failed').replace(/^Error:\s*/, ''), 40)
226    return { text: lines.length > 1 ? `${first} (+${plural(lines.length - 1, 'line')})` : first, isError: true }
227  }
228  const result = output as ShellOutput | undefined
229  if (result?.backgroundTaskId) return { text: 'running in background', isError: false }
230  const lines = lineCount(result?.stdout) + lineCount(result?.stderr)
231  if (lines > 0) return { text: plural(lines, 'line'), isError: false }
232  return { text: result?.returnCodeInterpretation || 'no output', isError: false }
233}
234
235export const register: Register = (on, options) => {
236  // Diffs with this many changed lines or fewer are drawn in full; 0 hides every diff.
237  const smallDiffLines = Math.max(0, Number(options.smallDiffLines ?? 5))
238  const isSmall = (change: Change) => change.added + change.removed <= smallDiffLines
239  // Marks where a diff was hidden; empty for none.
240  const icon = String(options.icon ?? '🔶').trim()
241  const lead = icon ? `${icon} ` : ''
242  // Puts the counts on the call's own line and drops the summary line under it.
243  const compact = options.compact === true
244
245  // Restore the last toggle from the store, so it survives restarts, and record the starting root.
246  on('session.start', async ($, e, next) => {
247    if ((await read($, startRoot)) === null) {
248      const root = await $.session.root()
249      await update($, startRoot, () => root)
250    }
251    const saved = await $.store.get(STORE_KEY)
252    if (typeof saved === 'boolean') await update($, isHiding, () => saved)
253    const savedFolding = await $.store.get(COMMANDS_STORE_KEY)
254    if (typeof savedFolding === 'boolean') await update($, isFoldingCommands, () => savedFolding)
255    return next(e)
256  })
257
258  // Record each finished shell command's outcome for its folded line.
259  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
260    const ran = await next(e)
261    if (ran.deny !== undefined) return ran
262    const outcome = commandOutcome(ran.isError === true, ran.isError ? ran.text : ran.result)
263    await update($, outcomes, current => ({ ...current, [e.tool_use_id]: outcome }))
264    return ran
265  })
266
267  // Add each finished change to the session tally shown on the switch.
268  on('tool.call', { tool: TRACKED_TOOLS }, async ($, e, next) => {
269    const ran = await next(e)
270    if (ran.deny !== undefined || ran.isError) return ran
271
272    const files: string[] = []
273    let change: Change = { added: 0, removed: 0 }
274    if (e.tool === 'Bash') {
275      const diff = (ran.result as BashOutput | undefined)?.bashEditDiff
276      if (!diff) return ran
277      files.push(...diff.files.map(file => file.filePath))
278      change = countChangedLines(diff.files.flatMap(file => file.hunks))
279    } else if (e.tool === 'NotebookEdit') {
280      const output = ran.result as NotebookOutput | undefined
281      if (!output?.notebook_path) return ran
282      files.push(output.notebook_path)
283      change = countNotebookLines(output)
284    } else {
285      const output = ran.result as FileChangeOutput | undefined
286      const changed = output?.filePath ? fileChange(output) : undefined
287      if (!output || !changed) return ran
288      files.push(output.filePath)
289      change = changed
290    }
291
292    await update($, tally, current => ({
293      files: [...new Set([...current.files, ...files])],
294      added: current.added + change.added,
295      removed: current.removed + change.removed,
296    }))
297    await update($, changes, current => ({ ...current, [e.tool_use_id]: change }))
298    return ran
299  })
300
301  // A small dim switch above the prompt; it must stay mounted for the chord to reach it.
302  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
303    if (e.props.hasSurvey) return next(e)
304
305    const hiding = await read($, isHiding)
306    const folding = await read($, isFoldingCommands)
307    const totals = await read($, tally)
308    const { Box, Button, Text } = $.ui.resolve(e)
309    const fileCount = totals.files.length
310    const tallyText = fileCount
311      ? ` · ${fileCount} file${fileCount === 1 ? '' : 's'} ${summarize(totals)} this session`
312      : ''
313
314    return (
315      <Box>
316        <Button
317          action={TOGGLE_ACTION}
318          dimColor
319          key="toggle-diffs"
320          label={hiding ? 'Diffs hidden (ctrl+q to show)' : 'Diffs shown (ctrl+q to hide)'}
321          onPress={async () => {
322            const nowHiding = await update($, isHiding, value => !value)
323            await $.store.set(STORE_KEY, nowHiding)
324            $.ui.toast(nowHiding ? 'Diffs hidden' : 'Diffs shown')
325          }}
326          plain
327        />
328        <Text dimColor> · </Text>
329        <Button
330          action={COMMANDS_ACTION}
331          dimColor
332          key="toggle-commands"
333          label={folding ? 'Commands folded (alt+q to show)' : 'Commands shown (alt+q to fold)'}
334          onPress={async () => {
335            const nowFolding = await update($, isFoldingCommands, value => !value)
336            await $.store.set(COMMANDS_STORE_KEY, nowFolding)
337            $.ui.toast(nowFolding ? 'Commands folded to one line' : 'Commands shown in full')
338          }}
339          plain
340        />
341        {tallyText ? <Text dimColor>{tallyText}</Text> : null}
342      </Box>
343    )
344  })
345
346  // The transcript folds runs of edits into one line; while diffs are shown, unfold
347  // those runs so ctrl+q visibly brings every diff back.
348  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
349    if (e.props.isExpanded || !e.props.calls.some(call => FILE_TOOLS.has(call.tool))) return next(e)
350    if (!(await read($, isHiding))) return next({ ...e, props: { ...e.props, isExpanded: true } })
351
352    // Folded: lay the icon over the line's blank indent, so the engine's text stays where it is.
353    const row = await next(e)
354    if (!icon || e.surface !== 'terminal') return row
355    const { Box, Text } = $.ui.resolve(e)
356    return (
357      <Box>
358        {row}
359        <Box position="absolute" bottom={0} left={0}>
360          <Text>{icon}</Text>
361        </Box>
362      </Box>
363    )
364  })
365
366  // Compact: the counts ride on the call's own line (`● Write(src/a.ts)  (+12 −0)`) and
367  // the result line under it is dropped, so each hidden diff takes one line.
368  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
369    if (!compact || !LABELED_TOOLS.has(e.props.tool)) return next(e)
370    if (e.props.isRunning || e.props.isErrored || e.props.isInterrupted) return next(e)
371    const input = e.props.input as { file_path?: unknown; old_string?: unknown } | undefined
372    if (typeof input?.file_path !== 'string' || !(await read($, isHiding))) return next(e)
373
374    // The row's own result when it carries one, else what the call recorded.
375    const root = (await read($, startRoot)) ?? (await $.session.root())
376    const recorded = (await read($, changes))[e.props.tool_use_id]
377    const change =
378      describeChange(e.props.tool, e.props.output, root, isSmall)?.change ??
379      (recorded && !isSmall(recorded) ? recorded : undefined)
380    if (!change) return next(e)
381
382    // Hand the engine the path relative to the root, so the label's width is known, and
383    // leave its row whole (status dot and all): the counts are laid over its last line.
384    const path = shortPath(input.file_path, root)
385    const row = await next({ ...e, props: { ...e.props, input: { ...input, file_path: path } } })
386    const { Box, Text } = $.ui.resolve(e)
387    const counts = (
388      <Text dimColor>
389        {lead}
390        {coloredSummary(Text, change)}
391      </Text>
392    )
393    const labelWidth = textWidth(`● ${rowName(e.props.tool, input)}(${path})`)
394    const countsWidth = textWidth(`${lead}${summarize(change)}`)
395    const columns = e.viewport?.columns ?? 80
396
397    // A label too long for the line wraps; the counts go on a line of their own beneath it.
398    if (labelWidth + 2 + countsWidth > columns - GROUP_INDENT) {
399      return (
400        <Box flexDirection="column">
401          {row}
402          <Box>
403            <Text dimColor>{ROW_GUTTER}</Text>
404            {counts}
405          </Box>
406        </Box>
407      )
408    }
409    return (
410      <Box>
411        {row}
412        <Box position="absolute" bottom={0} left={labelWidth + 2}>
413          {counts}
414        </Box>
415      </Box>
416    )
417  })
418
419  // Folded commands: a finished shell call draws one line of its own, `● Bash(cmd…)  ⎿  16 lines`,
420  // and its output is dropped; a running one keeps the engine's row, its command cut to one line.
421  // A command whose file changes are on show (ctrl+q) stays whole.
422  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
423    if (e.surface !== 'terminal' || !SHELL_TOOLS.has(e.props.tool) || e.props.isInterrupted) return next(e)
424    const input = e.props.input as { command?: unknown } | undefined
425    if (typeof input?.command !== 'string' || !(await read($, isFoldingCommands))) return next(e)
426    const output = e.props.isErrored ? undefined : (e.props.output as ShellOutput | undefined)
427    if (!(await read($, isHiding)) && output?.bashEditDiff?.files.length) return next(e)
428
429    const width = (e.viewport?.columns ?? 80) - GROUP_INDENT
430    const command = input.command.replace(/\s+/g, ' ').trim()
431    const head = `● ${e.props.tool}()`
432    const outcome =
433      e.props.output !== undefined ? commandOutcome(e.props.isErrored, e.props.output) : (await read($, outcomes))[e.props.tool_use_id]
434    if (e.props.isRunning || !outcome) {
435      const fitted = fitWidth(command, Math.max(12, width - textWidth(head)))
436      return next({ ...e, props: { ...e.props, input: { ...input, command: fitted } } })
437    }
438
439    // What the command changed in files rides on the line too; its diff is never drawn.
440    const edits = output?.bashEditDiff
441    const change = edits?.files.length ? countChangedLines(edits.files.flatMap(file => file.hunks)) : undefined
442    const files = edits ? plural(edits.files.length + edits.moreFiles, 'file') : ''
443    const tail = `  ⎿  ${outcome.text}${change ? ` · ${lead}${files} ${summarize(change)}` : ''}`
444    const fitted = fitWidth(command, Math.max(12, width - textWidth(head) - textWidth(tail)))
445    const { Box, Text } = $.ui.resolve(e)
446    return (
447      <Box>
448        <Text color={outcome.isError ? 'error' : 'success'}>● </Text>
449        <Text bold>{e.props.tool}</Text>
450        <Text>({fitted})</Text>
451        <Text dimColor>  ⎿  </Text>
452        {outcome.isError ? <Text color="error">{outcome.text}</Text> : <Text dimColor>{outcome.text}</Text>}
453        {change ? (
454          <Text dimColor>
455            {' · '}
456            {lead}
457            {files} {coloredSummary(Text, change)}
458          </Text>
459        ) : null}
460      </Box>
461    )
462  })
463
464  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
465    // A folded command's row carries its outcome, so its result block is dropped.
466    if (e.surface === 'terminal' && SHELL_TOOLS.has(e.props.tool) && (await read($, isFoldingCommands))) {
467      const output = e.props.isErrored ? undefined : (e.props.output as ShellOutput | undefined)
468      const showsEdits = !(await read($, isHiding)) && output?.bashEditDiff?.files.length
469      if (!output?.interrupted && !showsEdits) {
470        const { Box } = $.ui.resolve(e)
471        return <Box />
472      }
473    }
474    if (e.props.isErrored || !(await read($, isHiding))) return next(e)
475
476    const root = (await read($, startRoot)) ?? (await $.session.root())
477    const hidden = describeChange(e.props.tool, e.props.output, root, isSmall)
478    if (!hidden) return next(e)
479    const { Box, Text } = $.ui.resolve(e)
480
481    // Bash: draw its own output, minus the per-file diff of what the command changed.
482    if (e.props.tool === 'Bash') {
483      const { bashEditDiff: _hidden, ...outputWithoutDiff } = e.props.output as BashOutput
484      const drawn = await next({ ...e, props: { ...e.props, output: outputWithoutDiff } })
485      return (
486        <Box flexDirection="column">
487          {drawn}
488          <Text dimColor>
489            {lead}
490            {hidden.what} {coloredSummary(Text, hidden.change)}
491          </Text>
492        </Box>
493      )
494    }
495
496    // Compact: an Edit or Write row already carries the counts.
497    if (compact && LABELED_TOOLS.has(e.props.tool)) return <Box />
498
499    return (
500      <Text dimColor>
501        {lead}
502        {hidden.what} {coloredSummary(Text, hidden.change)}
503      </Text>
504    )
505  })
506}
507
types/index.d.ts 21 lines
1export type SessionTally = { files: string[]; added: number; removed: number }
2// The lines one finished call added and removed, keyed by its tool_use_id.
3export type CallChanges = Record<string, { added: number; removed: number }>
4// What a folded shell command's line says of its result (`16 lines`, `Exit code 1`).
5export type CommandOutcome = { text: string; isError: boolean }
6// Each finished shell call's outcome, keyed by its tool_use_id.
7export type CommandOutcomes = Record<string, CommandOutcome>
8
9declare module 'claude-code' {
10  interface PluginState {
11    'hide-diffs': {
12      isHiding: boolean
13      tally: SessionTally
14      startRoot: string | null
15      changes: CallChanges
16      isFoldingCommands: boolean
17      outcomes: CommandOutcomes
18    }
19  }
20}
21