Draws each tool call as one line, so the conversation reads straight through. Click a line to open the call.

The cleaner-tool-lines mod replaces the row and the result block of each tool call in the Claude Code terminal transcript with one line, so the conversation reads straight through:
● ▸ tool_call: Read(…/src/app/main.py)
● ▸ tool_call: Bash(npm test) - error
<img src="../media/cleaner-tool-lines-default.gif" alt="Claude Code fixes a failing test with four tool calls, folded into one line that names the call that is running. The pointer opens the line to its calls, and then opens the edit." width="32%"> <img src="../media/cleaner-tool-lines-counts.gif" alt="The same task with the running call not shown: the one line counts the calls by tool while they run." width="32%"> <img src="../media/cleaner-tool-lines-lines.gif" alt="The same task with collapsing off: each of the four tool calls has its own line. The pointer opens the edit." width="32%">
From left to right: the default, Show the running tool call turned off, and Collapse tool calls turned off. The settings are described below.
The dot shows how the call stands:
A long argument is cut to fit one row. A file path keeps its end, where the file name is.
Click a line to open that call. A shaded panel under the line shows the whole argument of the call, followed by the result. A shell command appears as highlighted code with its line breaks. To close the call, click its first row again. When you hold the pointer over a line, only that line is highlighted.
In the full transcript (<kbd>Control+O</kbd>) and under claude --verbose, Claude Code draws every tool call itself.
The mod takes its colors from your Claude Code theme and needs no other mod.
Claude often makes several tool calls in a row before it writes again. The mod folds such a run into one line, whichever tools the calls use. While Claude is still making calls, the line shows the latest one:
● ▸ 4 tool_calls: Edit(…/src/app/main.py)
When Claude writes again, when a prompt that you typed while it worked reaches it, or when the turn ends, the line counts the calls by tool:
● ▸ 6 tool_calls: 3 Bash, 1 Write, 1 Edit, 1 mcp__inbox__inbox - 1 failed
Click the line of a run to list its calls, each on a line that you can open. Click the line again to fold the run. A call with no other call next to it keeps its own line.
Two settings change this. To set one, run /config and search for its name:
Set "tui": "fullscreen" in your Claude Code settings. Claude Code reports clicks only in the fullscreen interface.
claude plugin marketplace add macwilk/cc-extensions
claude plugin install cleaner-tool-lines@cc-extensions
In a Claude Code session that is already open, run /reload-plugins.
To receive new versions, run /plugin, go to the Marketplaces tab, select cc-extensions, and then select Enable auto-update. Claude Code leaves auto-update off for this marketplace.
hooks/register.ts 323 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { cellsIn, closedSubject, cut, detailOf, dotColorOf, noteOf } from './line'
5import { type Book, type Mark, type Spree, emptyBook, end, join, note, restore, tallyOf, thoughtSplits } from './spree'
6
7const INDENT = 2
8const DOT = '● '
9// How far the lines of an open spree stand in from the line that counts them.
10const LISTED = 2
11// The viewport is absent until the terminal has been measured.
12const UNMEASURED_COLUMNS = 80
13
14const isOpen = atom({ plugin: 'cleaner-tool-lines', key: 'isOpen' } as const, false)
15// Whether the person opened a spree to a line for each of its calls. A spree goes by its first call.
16const isSpreeOpen = atom({ plugin: 'cleaner-tool-lines', key: 'isSpreeOpen' } as const, false)
17// Counts the changes to a spree. A row reads the count of its spree while it draws, so a change draws
18// the rows of that spree again and no other row of the transcript.
19const spreeChanges = atom({ plugin: 'cleaner-tool-lines', key: 'spreeChanges' } as const, 0)
20// The calls that open a spree because Claude Code drew a note of Claude's thinking before them. The
21// rows of the conversation do not tell such a note from thinking that is not drawn, so the mod keeps
22// the ones it saw arrive.
23const thoughtSplitsSeen = atom({ plugin: 'cleaner-tool-lines', key: 'thoughtSplitsSeen' } as const, [] as string[])
24
25
26// The shade an open call's input and result are set on, apart from the lines around them. A shade,
27// not a frame: a result that draws nothing then leaves nothing behind.
28const PANEL = { marginLeft: INDENT + DOT.length, paddingX: 1, backgroundColor: 'userMessageBackground', flexDirection: 'column' } as const
29
30// A background that passes as a color and names none: Claude Code draws no color for it, and a Box set
31// on it still keeps the background of the Box around it out.
32const NO_COLOR = 'ansi256()'
33
34// A tool row is not told which view it is drawn in, or whether it belongs to a folded run. The rows
35// that are told (the person's prompts, the runs) record it here.
36let isFullTranscript = false
37const callsInRuns = new Set<string>()
38
39const STATE_COLOR = { running: 'warning', done: 'success', failed: 'error' } as const
40
41/** The color a part of a line takes: its own, or the theme's gray on a line the setting grays. */
42function tint(isGray: boolean, color?: string): string | undefined {
43 return isGray ? 'inactive' : color
44}
45
46type Elements = ReturnType<EngineInterface['ui']['resolve']>
47type Settings = { isShowingRunning: boolean; isGraying: boolean }
48/** A spree folded into one line: the calls of it that have a row, and whether the person opened it. */
49type Fold = { spree: Spree; first: string; shown: string[]; marks: Mark[]; isOpen: boolean }
50
51/** The fold a call is in. A spree of one call is no fold: that call is its own line. */
52async function foldOf($: EngineInterface, book: Book, id: string | undefined): Promise<Fold | undefined> {
53 const spree = id === undefined || isFullTranscript ? undefined : book.spreeOf.get(id)
54 const first = spree?.calls[0]
55 if (spree === undefined || first === undefined) return undefined
56 // Read for the redraw alone, and before a lone call returns: it folds when a second one joins it.
57 await read($, memberOf(spreeChanges, { requestId: first }))
58 // The model can begin a call that never runs and so never gets a row.
59 const shown = spree.calls.filter(call => book.markOf.has(call))
60 if (shown.length < 2) return undefined
61 const marks = shown.flatMap(call => book.markOf.get(call) ?? [])
62 return { spree, first, shown, marks, isOpen: await read($, memberOf(isSpreeOpen, { requestId: first })) }
63}
64
65/** Draws the rows of a spree again, the spree named by its first call. */
66async function redraw($: EngineInterface, first: string | undefined) {
67 if (first !== undefined) await update($, memberOf(spreeChanges, { requestId: first }), count => count + 1)
68}
69
70/** The one line of a spree: how many calls, then what they were. A press opens and closes the spree. */
71function spreeLine($: EngineInterface, { Box, Text, Button }: Elements, fold: Fold, settings: Settings, columns: number) {
72 // Where the setting asks, a spree still under way names its latest call, and its dot and note are that call's.
73 const naming = settings.isShowingRunning && fold.spree.isLive && !fold.isOpen ? fold.marks.at(-1) : undefined
74 const isGray = settings.isGraying && !fold.isOpen
75 const failed = fold.marks.filter(mark => mark.state === 'failed').length
76 const state = naming?.state ?? (fold.marks.some(mark => mark.state === 'running') ? 'running' : failed > 0 ? 'failed' : 'done')
77 const head = `${fold.isOpen ? '▾' : '▸'} ${fold.marks.length} tool_calls${fold.isOpen ? '' : ': '}`
78 const said = naming === undefined ? (failed === 0 ? undefined : `${failed} failed`) : naming.note
79 const noteText = said === undefined ? '' : ` - ${said}`
80 const room = columns - INDENT - DOT.length - cellsIn(`${head}${noteText}`)
81 const told =
82 naming !== undefined
83 ? `${naming.tool}(${cut(naming.subject, room - cellsIn(`${naming.tool}()`))})`
84 : fold.isOpen
85 ? ''
86 : cut({ text: tallyOf(fold.marks), isPath: false }, room)
87 return Box({
88 flexDirection: 'row',
89 marginLeft: INDENT,
90 children: [
91 Text({ color: STATE_COLOR[state], children: [DOT] }),
92 Button({
93 key: 'spree',
94 plain: true,
95 onPress: () => update($, memberOf(isSpreeOpen, { requestId: fold.first }), wasOpen => !wasOpen),
96 children: [
97 Text({ color: 'inactive', children: [head] }),
98 ...(naming === undefined
99 ? [Text({ color: tint(isGray), children: [told] })]
100 : [
101 Text({ bold: true, color: tint(isGray), children: [naming.tool] }),
102 Text({ color: tint(isGray), children: [told.slice(naming.tool.length)] }),
103 ]),
104 ...(noteText === '' ? [] : [Text({ color: tint(isGray, state === 'failed' ? 'error' : 'inactive'), children: [noteText] })]),
105 ' '.repeat(Math.max(0, room - cellsIn(told))),
106 ],
107 }),
108 ],
109 })
110}
111
112export const register: Register = (on, options) => {
113 // Collapsing folds a spree, the tool calls Claude makes between two pieces of its reply, into one
114 // line that counts them by tool. Off, every call has its line. Showing the running call has that
115 // one line name the latest call while Claude is still making calls. Both are on unless set off.
116 // Graying draws every closed line in gray, and is off unless set on.
117 const isCollapsing = options.collapse !== false
118 const settings: Settings = { isShowingRunning: options.showRunning !== false, isGraying: options.grayscale === true }
119 const book = emptyBook()
120
121 // The conversations whose response under way last drew a note of Claude's thinking: the main one
122 // goes by '', and a subagent's by its id.
123 const afterThought = new Set<string>()
124 // Whether the person has typed a prompt into the turn under way that Claude has not been sent yet.
125 let hasTypedAhead = false
126
127 if (isCollapsing) {
128 on('session.start', async ($, e, next) => {
129 const splits = [...(await read($, thoughtSplitsSeen)), ...thoughtSplits(await $.session.messages({ as: 'api' }))]
130 restore(book, await $.session.messages(), new Set(splits))
131 // A subagent's conversation is drawn with the same rows when the person opens it.
132 for (const agent of await $.agent.list().catch(() => [])) {
133 const rows = await $.session.messages({ agentId: agent.id }).catch(() => [])
134 if (Array.isArray(rows)) restore(book, rows, new Set(splits), agent.id)
135 }
136 // The rows drawn while the conversation was read knew no spree.
137 $.ui.invalidate('ui.render')
138 return next(e)
139 })
140
141 // A prompt submitted while a turn runs carries that turn's id: it waits for the turn's next request.
142 on('prompt.submit', async ($, e, next) => {
143 if (e.origin.kind === 'composer' && e.turnId !== undefined) hasTypedAhead = true
144 return next(e)
145 })
146
147 // The response says where a spree begins and ends before any row is drawn: each call as the
148 // model begins it, and what Claude Code draws between two sprees, which is Claude's text and any
149 // thinking that comes with text. A subagent's responses do the same for its own conversation,
150 // whose sprees are kept apart from the main one's.
151 on('turn.step', async function* ($, e, next) {
152 const loop = e.agentId ?? ''
153 // A prompt typed while Claude works goes out with the turn's next request, and Claude Code
154 // draws it there, between the calls made before it and the calls made after. So the spree
155 // before it has ended, as at any text.
156 if (e.index === 0 || (loop === '' && hasTypedAhead)) await redraw($, end(book, loop))
157 if (loop === '') hasTypedAhead = false
158 for await (const chunk of next(e)) {
159 if (chunk.kind === 'tool') {
160 const first = join(book, chunk.id, loop)
161 if (afterThought.has(loop) && first === chunk.id) await update($, thoughtSplitsSeen, seen => [...seen, chunk.id])
162 afterThought.delete(loop)
163 } else if ((chunk.kind === 'text' || chunk.kind === 'thinking') && chunk.text.trim() !== '') {
164 if (chunk.kind === 'thinking') afterThought.add(loop)
165 else afterThought.delete(loop)
166 await redraw($, end(book, loop))
167 }
168 yield chunk
169 }
170 })
171
172 on('turn.complete', async ($, e, next) => {
173 await redraw($, end(book, e.agentId ?? ''))
174 return next(e)
175 })
176
177 // A row may not ask for another row to be drawn again, so the call does: when it starts and when
178 // it ends, which are the two times the line of its spree has something new to say.
179 on('tool.call', async ($, e, next) => {
180 const { tool, tool_use_id: id, agentId, requestMeta, ...input } = e
181 const first = book.spreeOf.get(id)?.calls[0]
182 note(book, id, { tool, input, isRunning: true, isErrored: false, isInterrupted: false })
183 await redraw($, first)
184 try {
185 const answer = await next(e)
186 const isErrored = answer.isError === true || answer.deny !== undefined
187 note(book, id, { tool, input, output: answer.result ?? answer.text ?? '', isRunning: false, isErrored, isInterrupted: false })
188 return answer
189 } finally {
190 await redraw($, first)
191 }
192 })
193 }
194
195 on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'composer' } } }, async ($, e, next) => {
196 if (e.surface === 'terminal' && e.props.isExpanded !== isFullTranscript) {
197 isFullTranscript = e.props.isExpanded
198 // Tool rows keep their last drawing until asked again.
199 $.ui.invalidate('ui.render')
200 }
201 return next(e)
202 })
203
204 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
205 if (e.surface !== 'terminal') return next(e)
206 let hasChanged = false
207 for (const call of e.props.calls) {
208 const id = call.tool_use_id
209 if (id === undefined) continue
210 if (isCollapsing) note(book, id, call)
211 if (callsInRuns.has(id)) continue
212 callsInRuns.add(id)
213 hasChanged = true
214 }
215 if (hasChanged) $.ui.invalidate('ui.render')
216 const elements = $.ui.resolve(e)
217 const { Box } = elements
218 const columns = e.viewport?.columns ?? UNMEASURED_COLUMNS
219 const fold = await foldOf($, book, e.props.calls[0]?.tool_use_id)
220 const count = fold !== undefined && e.props.calls.some(call => call.tool_use_id === fold.shown[0]) ? [spreeLine($, elements, fold, settings, columns)] : []
221 // A run Claude Code marks expanded is shaded whole, with a spare row beneath it. The Box draws the
222 // terminal's own background over the shade, and its margin takes the row back.
223 const cover = e.props.isExpanded ? { backgroundColor: NO_COLOR, marginBottom: -1 } : {}
224 // A folded spree is one line, on the row of its first call. Every other row of it draws nothing.
225 if (fold !== undefined && !fold.isOpen) return Box({ ...cover, flexDirection: 'column', children: count })
226 // Unfolded, each call of the run is drawn through the ToolUse hook below. That holds when Claude
227 // Code marks the run expanded too: a click on an open call's input or result is a click on the run
228 // to Claude Code, and handing the run back there would redraw every call in it natively.
229 const run = await next({ ...e, props: { ...e.props, isExpanded: true } })
230 // The full view draws every run expanded and marks none.
231 if (isFullTranscript || (count.length === 0 && !e.props.isExpanded)) return run
232 return Box({ ...cover, flexDirection: 'column', children: [...count, run] })
233 })
234
235 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
236 if (e.surface !== 'terminal') return next(e)
237 if (isFullTranscript) return next(e)
238 const elements = $.ui.resolve(e)
239 const { Box, Text, Button, Code } = elements
240 const id = e.props.tool_use_id
241 if (isCollapsing) note(book, id, e.props)
242 const columns = e.viewport?.columns ?? UNMEASURED_COLUMNS
243 const inRun = callsInRuns.has(id)
244 const fold = await foldOf($, book, id)
245 // A run draws the line of its spree itself, above the calls it holds.
246 const count = fold !== undefined && !inRun && fold.shown[0] === id ? [spreeLine($, elements, fold, settings, columns)] : []
247 if (fold !== undefined && !fold.isOpen) return Box({ flexDirection: 'column', children: count })
248 const inset = fold === undefined ? 0 : LISTED
249 const mine = memberOf(isOpen, e)
250 const open = await read($, mine)
251
252 const head = `${open ? '▾' : '▸'} tool_call: `
253 const callNote = noteOf(e.props)
254 const noteText = callNote === undefined ? '' : ` - ${callNote}`
255 const lineRoom = columns - INDENT - inset - DOT.length
256 const subject = open ? '' : `(${closedSubject(e.props.input, lineRoom - cellsIn(`${head}${e.props.tool}()${noteText}`))})`
257 // A press lands on the button alone, so spaces carry the button on to the edge of the row.
258 const fill = ' '.repeat(Math.max(0, lineRoom - cellsIn(`${head}${e.props.tool}${subject}${noteText}`)))
259 const isGray = settings.isGraying && !open
260 const line = Box({
261 flexDirection: 'row',
262 marginLeft: INDENT + inset,
263 children: [
264 Text({ color: dotColorOf(e.props), children: [DOT] }),
265 // One press wherever on the row it lands. An open call keeps the button to the row of its head,
266 // so its input below can be read, selected and copied without the pointer inverting it.
267 Button({
268 key: 'toggle',
269 plain: true,
270 onPress: () => update($, mine, wasOpen => !wasOpen),
271 children: [
272 // A color, not dimColor: while the pointer is on a folded run, Claude Code draws every dim
273 // text in the run at full strength, which would light this line whenever a neighbour is hovered.
274 Text({ color: 'inactive', children: [head] }),
275 Text({ bold: true, color: tint(isGray), children: [e.props.tool] }),
276 Text({ color: tint(isGray), children: [subject] }),
277 ...(callNote === undefined
278 ? []
279 : [Text({ color: tint(isGray, e.props.isErrored || e.props.isInterrupted ? 'error' : 'inactive'), children: [noteText] })]),
280 fill,
281 ],
282 }),
283 ],
284 })
285 const closed = count.length === 0 ? line : Box({ flexDirection: 'column', children: [...count, line] })
286 if (!open) return closed
287
288 const detail = detailOf(e.props.input)
289 const input =
290 detail.text === ''
291 ? []
292 : [detail.language === undefined ? Text({ children: [detail.text] }) : Code({ source: detail.text, language: detail.language })]
293 // A standalone call's result is the ToolResult row beneath; a call of a run has no row but this one,
294 // and nothing to show in it until its result is stored. Claude Code draws such a call as a header
295 // named by `tool` over its result, one row down: an empty name leaves the header out, and the margin
296 // takes that row back.
297 const result =
298 inRun && e.props.output !== undefined
299 ? [Box({ marginTop: -1, flexDirection: 'column', children: [await next({ ...e, props: { ...e.props, tool: '' } })] })]
300 : []
301 if (input.length + result.length === 0) return closed
302 return Box({ flexDirection: 'column', children: [...count, line, Box({ ...PANEL, marginLeft: PANEL.marginLeft + inset, children: [...input, ...result] })] })
303 })
304
305 // Claude Code's hint to send a long command to the background is a row of its own under the call.
306 // Under a folded spree it would hang beneath a line that is not that call's, so there it is left out.
307 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
308 if (isFullTranscript) return next(e)
309 const fold = await foldOf($, book, e.props.tool_use_id)
310 return next(fold !== undefined && !fold.isOpen ? { ...e, props: { ...e.props, hint: '' } } : e)
311 })
312
313 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
314 if (e.surface !== 'terminal' || isFullTranscript) return next(e)
315 const { Box } = $.ui.resolve(e)
316 const fold = await foldOf($, book, e.props.tool_use_id)
317 if (fold !== undefined && !fold.isOpen) return Box({})
318 const inset = fold === undefined ? 0 : LISTED
319 if (await read($, memberOf(isOpen, e))) return Box({ ...PANEL, marginLeft: PANEL.marginLeft + inset, children: [await next(e)] })
320 return Box({})
321 })
322}
323hooks/line.ts 111 lines1/** What a tool call's row carries, as `ui.render` hands it over. */
2export type Call = {
3 tool: string
4 input: unknown
5 output?: unknown
6 isRunning: boolean
7 isErrored: boolean
8 isInterrupted: boolean
9}
10
11// The input fields that say what a call acts on, the most telling first. An Agent call has both a
12// description and a prompt, and the description is the one written to be read at a glance.
13const SUBJECT_FIELDS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'skill', 'description', 'prompt']
14const PATH_FIELDS = new Set(['file_path', 'notebook_path'])
15// The fields that run long. An open call shows one of these whole in place of its subject.
16const WHOLE_FIELDS = ['command', 'prompt']
17
18function isRecord(value: unknown): value is Record<string, unknown> {
19 return typeof value === 'object' && value !== null
20}
21
22function onOneLine(text: string): string {
23 return text.replace(/\s+/g, ' ').trim()
24}
25
26export type Subject = { text: string; isPath: boolean }
27
28export function subjectOf(input: unknown): Subject {
29 if (!isRecord(input)) return { text: '', isPath: false }
30 for (const field of SUBJECT_FIELDS) {
31 const value = input[field]
32 if (typeof value === 'string' && value !== '') return { text: onOneLine(value), isPath: PATH_FIELDS.has(field) }
33 }
34 const raw = Object.keys(input).length === 0 ? '' : onOneLine(JSON.stringify(input))
35 return { text: raw, isPath: false }
36}
37
38/** How the call ended, where the dot's color alone does not say. */
39export function noteOf(call: Call): string | undefined {
40 if (call.isInterrupted) return 'interrupted'
41 if (call.isErrored) return 'error'
42 // An errored call's output is the text the model read, so only a result object has these fields.
43 if (!isRecord(call.output)) return undefined
44 if (call.output.backgroundTaskId !== undefined) return 'background'
45 // An Agent call resolves as soon as its agent is launched, while the agent itself goes on running.
46 if (call.output.status === 'async_launched' || call.output.status === 'remote_launched') return 'background'
47 if (call.output.timedOutAfterMs !== undefined) return 'timed out'
48 return undefined
49}
50
51// The blocks a terminal draws two cells wide: East Asian wide and fullwidth characters, and emoji.
52const TWO_CELLS = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u
53// Marks, joiners and variation selectors change the character before them and take no cell of their own.
54const NO_CELL = /[\p{M}\u200d\ufe00-\ufe0f]/u
55
56function cellsOf(character: string): number {
57 if (NO_CELL.test(character)) return 0
58 return TWO_CELLS.test(character) ? 2 : 1
59}
60
61export function cellsIn(text: string): number {
62 let cells = 0
63 for (const character of text) cells += cellsOf(character)
64 return cells
65}
66
67// A path says most at its end, where the file's name is. Anything else says most at its start.
68export function cut({ text, isPath }: Subject, room: number): string {
69 if (cellsIn(text) <= room) return text
70 const characters = isPath ? Array.from(text).reverse() : Array.from(text)
71 let kept = ''
72 // One cell is the ellipsis's.
73 let cells = 1
74 for (const character of characters) {
75 cells += cellsOf(character)
76 if (cells > room) break
77 kept = isPath ? character + kept : kept + character
78 }
79 return isPath ? '…' + kept : kept + '…'
80}
81
82/** A closed call's subject, cut to `room` cells. */
83export function closedSubject(input: unknown, room: number): string {
84 return cut(subjectOf(input), room)
85}
86
87/** What an open call shows of its input, and the language to color it in when it is code. */
88export type Detail = { text: string; language?: string }
89
90// Text and Code take tabs and line breaks and no other control character.
91function printable(text: string): string {
92 return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
93}
94
95/** An open call's command or prompt whole, else its subject whole, else all of its input. */
96export function detailOf(input: unknown): Detail {
97 if (!isRecord(input)) return { text: '' }
98 for (const field of [...WHOLE_FIELDS, ...SUBJECT_FIELDS]) {
99 const value = input[field]
100 if (typeof value !== 'string' || value === '') continue
101 return field === 'command' ? { text: printable(value), language: 'bash' } : { text: printable(value) }
102 }
103 return Object.keys(input).length === 0 ? { text: '' } : { text: JSON.stringify(input, null, 2), language: 'json' }
104}
105
106export function dotColorOf(call: Call): 'warning' | 'success' | 'error' {
107 if (call.isErrored || call.isInterrupted) return 'error'
108 // A call waiting on its turn or on approval is not running yet, so only a stored result marks it done.
109 return call.isRunning || call.output === undefined ? 'warning' : 'success'
110}
111hooks/spree.ts 114 lines1import type { SessionMessage } from 'claude-code'
2
3import { type Call, type Subject, cut, noteOf, subjectOf } from './line'
4
5// As much of a subject as the widest row shows.
6const SUBJECT_CELLS = 400
7
8/** The tool calls Claude makes between two pieces of its reply, in the order it made them. */
9export type Spree = { calls: string[]; isLive: boolean }
10
11/** What the one line of a spree says of a call that another row draws. */
12export type Mark = { tool: string; subject: Subject; state: 'running' | 'done' | 'failed'; note: string | undefined }
13
14/**
15 * Every spree by the calls in it, how each call stands, and the spree still under way in each
16 * conversation: the main one goes by '', and a subagent's, which the person can open, by its id.
17 */
18export type Book = { spreeOf: Map<string, Spree>; markOf: Map<string, Mark>; current: Map<string, Spree> }
19
20export function emptyBook(): Book {
21 return { spreeOf: new Map(), markOf: new Map(), current: new Map() }
22}
23
24/** Adds a new call to the spree under way in its conversation, starting one where none is. Returns the first call of the spree it changed. */
25export function join(book: Book, id: string, loop = ''): string | undefined {
26 if (book.spreeOf.has(id)) return undefined
27 const spree = book.current.get(loop) ?? { calls: [], isLive: true }
28 book.current.set(loop, spree)
29 spree.calls.push(id)
30 spree.isLive = true
31 book.spreeOf.set(id, spree)
32 return spree.calls[0]
33}
34
35/** Ends the spree under way in a conversation, so the next call there starts another. Returns the first call of the spree it ended. */
36export function end(book: Book, loop = ''): string | undefined {
37 const ended = book.current.get(loop)
38 if (ended === undefined) return undefined
39 ended.isLive = false
40 book.current.delete(loop)
41 return ended.calls[0]
42}
43
44function markOf(call: Call): Mark {
45 const subject = subjectOf(call.input)
46 return {
47 tool: call.tool,
48 subject: { text: cut(subject, SUBJECT_CELLS), isPath: subject.isPath },
49 // A call waiting on its turn or on approval is not running yet, so only a stored result marks it done.
50 state: call.isErrored || call.isInterrupted ? 'failed' : call.isRunning || call.output === undefined ? 'running' : 'done',
51 note: noteOf(call),
52 }
53}
54
55/** Records how a call stands, as its row was last handed it or as the call itself reported. */
56export function note(book: Book, id: string, call: Call): void {
57 const mark = markOf(call)
58 const was = book.markOf.get(id)
59 // A row can be drawn once more as running after its call has ended.
60 const hasEnded = was !== undefined && was.state !== 'running' && mark.state === 'running'
61 book.markOf.set(id, hasEnded ? { ...mark, state: was.state, note: was.note } : mark)
62}
63
64type RequestMessage = { content: readonly { type: string; [field: string]: unknown }[] }
65
66/**
67 * The calls that open a spree because Claude Code draws a note of Claude's thinking before them, as
68 * it draws any thinking that comes with text. Only the request form of the conversation holds the
69 * thinking, and only for the part since its last compaction.
70 */
71export function thoughtSplits(messages: readonly RequestMessage[]): string[] {
72 const splits: string[] = []
73 let isAfterThought = false
74 for (const block of messages.flatMap(message => message.content)) {
75 if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.trim() !== '') isAfterThought = true
76 if (block.type !== 'tool_use' || typeof block.id !== 'string') continue
77 if (isAfterThought) splits.push(block.id)
78 isAfterThought = false
79 }
80 return splits
81}
82
83/**
84 * Reads the sprees of a conversation off its messages, for the calls made before the mod was loaded.
85 * Text ends a spree, Claude's or the person's, and so does a note of thinking, which the messages do
86 * not show: `splits` names the calls that follow one. No spree is live: a call that follows the last
87 * one joins it and makes it so. The main conversation is read first, into an empty book, and each
88 * subagent's after it, under the subagent's id.
89 */
90export function restore(book: Book, messages: readonly SessionMessage[], splits: ReadonlySet<string>, loop = ''): void {
91 if (loop === '') {
92 book.spreeOf.clear()
93 book.markOf.clear()
94 book.current.clear()
95 }
96 for (const message of messages) {
97 if (message.text.trim() !== '') end(book, loop)
98 for (const use of message.toolUses) {
99 if (splits.has(use.tool_use_id)) end(book, loop)
100 join(book, use.tool_use_id, loop)
101 const call = { tool: use.tool, input: use.input, output: use.result ?? use.text, isRunning: false, isErrored: use.isError === true, isInterrupted: false }
102 book.markOf.set(use.tool_use_id, markOf(call))
103 }
104 }
105 for (const spree of book.spreeOf.values()) spree.isLive = false
106}
107
108/** `3 Bash, 2 Edit`: how many calls each tool took, the tools in the order Claude first called them. */
109export function tallyOf(marks: readonly Mark[]): string {
110 const counts = new Map<string, number>()
111 for (const { tool } of marks) counts.set(tool, (counts.get(tool) ?? 0) + 1)
112 return [...counts].map(([tool, count]) => `${count} ${tool}`).join(', ')
113}
114types/index.d.ts 13 lines1export type IsOpen = boolean
2
3declare module 'claude-code' {
4 interface PluginState {
5 'cleaner-tool-lines': {
6 isOpen: StateFamily<IsOpen>
7 isSpreeOpen: StateFamily<IsOpen>
8 spreeChanges: StateFamily<number>
9 thoughtSplitsSeen: string[]
10 }
11 }
12}
13