A Claude Code plugin that folds everything per turn.

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.· 1 failed).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.
Set them with /plugin configure dense@claude-code-dense or claude plugin configure dense.
| Option | Default | What it does |
|---|---|---|
foldFailedTurns | on | Fold turns with a failed tool call too. Off: such turns stay expanded. |
hideNotes | on | Hide the text between tool calls. Off: only tool calls fold. |
⎿ Read X (N lines), ⎿ Referenced file X).--resume aren't folded: the plugin only knows the turns it watched. ▸ expand .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.
hooks/register.tsx 214 lines1import { 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}
214types/index.d.ts 16 lines1/** 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