SLOPSHOPPER

dense

A Claude Code plugin that folds everything per turn.

newrowsguard
v0.1.1MITupdated 2026-10-07jinhuang712/claude-code-dense
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dense
› fix the failing auth test and add an audit log call ● dense: dense: folded turn-1: 1 read · 1 search · 3 edits · 4 bash ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-dense

A Claude Code plugin that folds everything per turn. While a turn runs you see every step. Once it ends, its tool calls and the text between them fold into one line above the final answer:

● 3 bash · 2 edits · 1 read · 1 mcp(lark) · 2 notes  ▸ expand
  • ▸ expand (green) opens that one turn; ▾ fold (red) closes it again. After a click the clicked line is scrolled to the top of the screen, so it doesn't jump out of view.
  • Failed calls are counted in red (· 1 failed).
  • A turn you interrupt (Esc) stays expanded.

Requirements

  • Claude Code with mods (plugins of function hooks). Tested on 2.1.292.
  • Mods are behind a rollout switch Anthropic controls. While it is off, the plugin installs but does not load.
  • Tested in the fullscreen view. In the normal view, rows already printed to the terminal's scrollback may not redraw.

Install

At the prompt of a Claude Code terminal session:

/plugin install dense --marketplace jinhuang712/claude-code-dense

Answer y to add the marketplace, then pick a scope (user is the first one offered).

From a local checkout instead:

claude plugin marketplace add ~/dev/claude-code/claude-code-dense
claude plugin install dense@claude-code-dense

Then run /reload-plugins in an open session. A plugin installed from a folder is read from that folder: after an edit, /reload-plugins picks it up without reinstalling.

Options

Set them with /plugin configure dense@claude-code-dense or claude plugin configure dense.

OptionDefaultWhat it does
foldFailedTurnsonFold turns with a failed tool call too. Off: such turns stay expanded.
hideNotesonHide the text between tool calls. Off: only tool calls fold.

Limits

  • Thinking blocks have no render hook, so they can't be folded.
  • Neither can rows Claude Code adds on its own, like the files it re-attaches after a compaction (⎿ Read X (N lines), ⎿ Referenced file X).
  • Turns from before a --resume aren't folded: the plugin only knows the turns it watched.
  • Folded rows stay folded in the ctrl+o transcript (tool rows don't say which view draws them); use ▸ expand .
  • A bash command that exits non-zero without output isn't reported as an error by Claude Code, so it isn't counted as failed.

Develop

claude plugin validate .
claude plugin test .
tsc -p .

tsc and the editor read Claude Code's types from .claude-plugin/types/ (git-ignored). Claude Code writes them there when it loads the plugin from --plugin-dir or a hot-reload mods folder; they change with each Claude Code build.

License

MIT

Source 2 files
hooks/register.tsx 214 lines
1import { update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderInput } from 'claude-code'
3
4import type { FoldRow } from '../types'
5
6const rowRef = { plugin: 'dense', key: 'row' } as const
7const expandedRef = { plugin: 'dense', key: 'expanded' } as const
8
9type Call = { id: string; tool: string; hasFailed: boolean }
10// responseRows: every main-loop response row of the turn (thinking, text,
11// tool_use); the engine may draw any of them as an AssistantMessage (a
12// summary of in-between text came under a thinking row's id).
13type Turn = { id: string; calls: Call[]; textRows: string[]; responseRows: string[] }
14
15// The transcript may draw a row under a provisional id: the real one with
16// everything past its 24th character zeroed (`bb1a1d64-d62b-42ca-b488-000…`,
17// `toolu_01J4ByJtnPPxSuzrT4000…`). Fold state is keyed by the shared head.
18function rowKey(id: string): string {
19  return id.slice(0, 24)
20}
21
22// How long a toggle waits for the unfolded rows to be laid out before it
23// scrolls; the state write redraws its readers at the redraw rate.
24const REDRAW_MS = 60
25
26// The turn being tracked; a reload mid-turn only loses that one turn's fold.
27let current: Turn | null = null
28
29const PLURAL: Record<string, [string, string]> = {
30  bash: ['bash', 'bash'],
31  edit: ['edit', 'edits'],
32  read: ['read', 'reads'],
33  search: ['search', 'searches'],
34  fetch: ['fetch', 'fetches'],
35  agent: ['agent', 'agents'],
36}
37
38// Named by what a call did: code, tool and web searches are all searches.
39function category(tool: string): string {
40  if (tool === 'Bash' || tool === 'PowerShell') return 'bash'
41  if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].includes(tool)) return 'edit'
42  if (tool === 'Read') return 'read'
43  if (['Grep', 'Glob', 'LS', 'ToolSearch', 'WebSearch'].includes(tool)) return 'search'
44  if (tool === 'WebFetch') return 'fetch'
45  if (tool === 'Agent' || tool === 'Task') return 'agent'
46  if (tool.startsWith('mcp__')) {
47    const server = (tool.split('__')[1] ?? 'mcp').replace(/^claude_ai_/, '')
48    return `mcp(${server})`
49  }
50  return tool
51}
52
53function summarize(turn: Turn, countNotes: boolean): string {
54  const counts = new Map<string, number>()
55  for (const call of turn.calls) {
56    const kind = category(call.tool)
57    counts.set(kind, (counts.get(kind) ?? 0) + 1)
58  }
59  const parts = [...counts].map(([kind, n]) => {
60    const names = PLURAL[kind]
61    return `${n} ${names ? names[n === 1 ? 0 : 1] : kind}`
62  })
63  const notes = turn.textRows.length - 1
64  if (countNotes && notes > 0) parts.push(`${notes} ${notes === 1 ? 'note' : 'notes'}`)
65  return parts.join(' · ')
66}
67
68async function drawFolded(
69  $: EngineInterface,
70  e: RenderInput,
71  row: FoldRow,
72  drawOwn: () => Promise<RenderElement>,
73): Promise<RenderElement> {
74  const { Box, Text, Button } = $.ui.resolve(e)
75  const turnExpanded = { ...expandedRef, id: row.turnId }
76  const { value: isOpen = false } = await $.state.get(turnExpanded)
77  if (row.role === 'hidden') return isOpen ? drawOwn() : <Box />
78  const toggle = async () => {
79    await update($, turnExpanded, open => !open)
80    // At the bottom the transcript sticks to its end, so rows unfolding below
81    // this line push it up and away. Once they are laid out, bring this line
82    // back to the top; no API holds a row at its old screen position.
83    await $.clock.sleep(REDRAW_MS)
84    try {
85      await $.ui.scroll({ to: { requestId: e.requestId }, block: 'start' })
86    } catch (err) {
87      $.ui.log(`dense: scroll after toggle failed: ${String(err)}`, { to: 'debug' })
88    }
89  }
90  // marginTop matches the gap the engine's own rows keep above themselves.
91  const header = (
92    <Box flexDirection="row" marginTop={1}>
93      <Text dimColor>
94        {'● '}
95        {row.text}
96      </Text>
97      {row.failed > 0 && <Text color="red">{` · ${row.failed} failed`}</Text>}
98      <Text> </Text>
99      {/* A Button paints in accent, plain or dim only; a Box behind it gives
100          the two states contrasting theme colors. */}
101      <Box backgroundColor={isOpen ? 'diffRemoved' : 'diffAdded'}>
102        <Button
103          key={`fold-${row.turnId}`}
104          label={isOpen ? ' ▾ fold ' : ' ▸ expand '}
105          plain
106          onPress={toggle}
107        />
108      </Box>
109    </Box>
110  )
111  if (!isOpen) return header
112  return (
113    <Box flexDirection="column">
114      {header}
115      {await drawOwn()}
116    </Box>
117  )
118}
119
120export const register: Register = (on, options) => {
121  const foldFailedTurns = options.foldFailedTurns !== false
122  const hideNotes = options.hideNotes !== false
123
124  on('turn.start', async ($, e, next) => {
125    current = { id: e.turnId, calls: [], textRows: [], responseRows: [] }
126    return next(e)
127  })
128
129  on('tool.call', async ($, e, next) => {
130    const turn = current
131    if (e.agentId !== undefined || turn === null) return next(e)
132    const call: Call = { id: e.tool_use_id, tool: e.tool, hasFailed: false }
133    turn.calls.push(call)
134    const ran = await next(e)
135    call.hasFailed = ran.deny !== undefined || ran.isError === true
136    return ran
137  }).catch(($, e, next) => next(e))
138
139  on('session.append', async ($, e, next) => {
140    const turn = current
141    if (e.agentId === undefined && e.door === 'response' && turn !== null) {
142      const content = e.message.content
143      const kinds = Array.isArray(content) ? content.map(block => block.type) : ['text']
144      turn.responseRows.push(e.uuid)
145      if (kinds.includes('text')) turn.textRows.push(e.uuid)
146    }
147    return next(e)
148  }).catch(($, e, next) => next(e))
149
150  on('turn.complete', async ($, e, next) => {
151    const turn = current
152    if (e.agentId !== undefined || turn === null || turn.id !== e.turnId) {
153      return next(e)
154    }
155    current = null
156    const failed = turn.calls.filter(call => call.hasFailed).length
157    const shouldFold =
158      turn.calls.length > 0 && e.reason === 'answer' && (foldFailedTurns || failed === 0)
159    if (shouldFold) {
160      const text = summarize(turn, hideNotes)
161      const [first, ...rest] = turn.calls
162      const answerRow = turn.textRows.at(-1)
163      const hiddenIds = [
164        ...rest.map(call => call.id),
165        ...(hideNotes ? turn.responseRows.filter(id => id !== answerRow) : []),
166      ]
167      await Promise.all([
168        first &&
169          $.state.set(
170            { ...rowRef, id: rowKey(first.id) },
171            { role: 'summary', turnId: turn.id, text, failed },
172          ),
173        ...hiddenIds.map(id =>
174          $.state.set({ ...rowRef, id: rowKey(id) }, { role: 'hidden', turnId: turn.id }),
175        ),
176      ])
177      $.ui.log(`dense: folded ${turn.id}: ${text}`, { to: 'debug' })
178    }
179    return next(e)
180  })
181
182  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
183    const { value: row = null } = await $.state.get({ ...rowRef, id: rowKey(e.requestId) })
184    if (row === null) return next(e)
185    return drawFolded($, e, row, () => next(e))
186  })
187
188  // A standalone call's result (`⎿ Added 9 lines`) is a row of its own, the
189  // tool_result's user message, so hiding the call's row leaves it drawn. It
190  // folds with its call, the summary row's included.
191  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
192    const { value: row = null } = await $.state.get({ ...rowRef, id: rowKey(e.requestId) })
193    if (row === null) return next(e)
194    return drawFolded($, e, { role: 'hidden', turnId: row.turnId }, () => next(e))
195  })
196
197  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
198    const ids = e.props.calls.flatMap(call => (call.tool_use_id === undefined ? [] : [call.tool_use_id]))
199    const reads = await Promise.all(ids.map(id => $.state.get({ ...rowRef, id: rowKey(id) })))
200    const rows = reads.flatMap(({ value }) => (value == null ? [] : [value]))
201    // A call the engine refused before it ran (an unknown tool) never reaches
202    // tool.call, so one known call is enough to fold the group with its turn.
203    const row = rows.find(r => r.role === 'summary') ?? rows[0]
204    if (row === undefined) return next(e)
205    return drawFolded($, e, row, () => next(e))
206  })
207
208  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
209    const { value: row = null } = await $.state.get({ ...rowRef, id: rowKey(e.requestId) })
210    if (row === null) return next(e)
211    return drawFolded($, e, row, () => next(e))
212  })
213}
214
types/index.d.ts 16 lines
1/** What a folded transcript row draws once its turn has ended. */
2export type FoldRow =
3  | { role: 'summary'; turnId: string; text: string; failed: number }
4  | { role: 'hidden'; turnId: string }
5
6declare module 'claude-code' {
7  interface PluginState {
8    dense: {
9      /** Per row, by rowKey of its tool_use_id or message id. */
10      row: StateFamily<FoldRow | null>
11      /** Per turn id: unfolded by its expand button. */
12      expanded: StateFamily<boolean>
13    }
14  }
15}
16