SLOPSHOPPER

cleaner-tool-lines

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

newrowsguardprompt
★ 1v?GPL-3.0-or-laterupdated 2026-10-14macwilk/cc-extensions/cleaner-tool-lines
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cleaner-tool-lines
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ● toggle ⏺ 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

Draws

Tool row
● toggle
README

cleaner-tool-lines

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:

  • Yellow: the call is waiting or running.
  • Green: the call finished.
  • Red: the call failed or was interrupted.

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.

Choose how much a run shows

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:

  • Collapse tool calls: turn it off to give every call its own line.
  • Show the running tool call: turn it off to have the line count the calls by tool while Claude is still making them, too.
  • Gray out closed tool calls: turn it on to have the line grayed out when collapsed, which makes it stand out less.

Before you begin

Set "tui": "fullscreen" in your Claude Code settings. Claude Code reports clicks only in the fullscreen interface.

Install the mod

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.

Source 4 files
hooks/register.ts 323 lines
1import { 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}
323
hooks/line.ts 111 lines
1/** 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}
111
hooks/spree.ts 114 lines
1import 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}
114
types/index.d.ts 13 lines
1export 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