SLOPSHOPPER

tool-lines

Draws each tool call as one compact line; click a line to see its full input and result in a side pane

newpanerowsguard
v0.2.1no licenseupdated 2026-10-08fgilio/tool-lines-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-lines
│ ┃ tool-detail ✕ ⟨Claude Code's own drawing⟩ │ ┃ Click a tool line to see its call here. │ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ● detail │ ⏺ 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

Pane · tool-detail
Click a tool line to see its call here.
Your message
⟨Claude Code's own drawing⟩
Tool row
● detail
README

tool-lines

A Claude Code mod that draws each tool call as one compact line and hides its result block. Click a line to see the full call in a side pane.

Forked from the tool-lines mod in Tickloop/claude-mods by Pulkit Arya. The compact line is their work. This fork adds the detail pane and shorter subjects.

● tool_call: Bash(grep -n "x" …/types/claude-code.d.ts)
● tool_call: Read(…/hooks/register.ts)
● tool_call: Edit(~/notes.md) - error

What it does

  • One line per call: tool_call: Tool(subject) - meta. The dot is yellow while the call runs, green when it is done, and red when it fails.
  • Readable subjects: the line drops shell setup (T=/path;, cd dir &&, set -e;), writes your home folder as ~, and keeps the last two segments of a long path. The subject uses the whole row.
  • Detail pane: click a line to open the call's full input and result in a side pane. Bash shows the command, stdout and stderr. Edit and Write show a diff. Read shows the numbered file lines. Other tools show their JSON. Click the same line again to close the pane.
  • ctrl+o: the expanded transcript stays as the engine draws it.

Clicks reach the mod in the fullscreen terminal.

Install

/plugin marketplace add fgilio/claude-plugins
/plugin install tool-lines@fgilio

Then run /reload-plugins.

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .

Credits

  • Original mod: tool-lines in Tickloop/claude-mods, forked at commit 1042852.
  • The original repository has no license, so this fork has none either. If you are its author and want it changed or taken down, open an issue.
Source 3 files
hooks/register.ts 249 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { ToolDetail, ToolFacts } from '../types'
5import { fieldsOf, inputSections, outputSections, statusOf, type Status } from './detail'
6
7// MARK: Layout
8
9// Wide terminals fit the whole line on one row; narrow ones may wrap, up to MAX_ROWS.
10const MIN_CHARS = 120
11const MAX_ROWS = 3
12// The engine indents transcript rows, so a line sized to the full width would spill onto a second row.
13const GUTTER = 4
14// The viewport is absent until the terminal has been measured.
15const FALLBACK_COLUMNS = 120
16
17// MARK: Row facts
18
19// Input fields that name what a call acts on, most telling first; add a key here to cover a new tool.
20const SUBJECT_KEYS = [
21  'command',
22  'file_path',
23  'notebook_path',
24  'pattern',
25  'url',
26  'query',
27  'skill',
28  'description',
29  'path',
30  'action',
31  'prompt',
32]
33
34// Setup the reader skips to find the command that does the work: `T=/long/path;` and `cd dir &&`.
35const SHELL_SETUP = /^(?:(?:[A-Za-z_]\w*=\S*|cd\s+\S+|set\s+-\w+)\s*(?:;|&&)\s*)+/
36const HOME = /(?:\/Users|\/home)\/[^/\s'"]+/g
37// A path past three segments keeps its last two: the folder and the file are what tell it apart.
38const LONG_PATH = /(?<![\w:/.~])(?:~|\.{0,2})?(?:\/[^/\s'"]+){3,}/g
39
40export function shortenSubject(subject: string): string {
41  return subject
42    .replace(SHELL_SETUP, '')
43    .replace(HOME, '~')
44    .replace(LONG_PATH, path => '…/' + path.split('/').slice(-2).join('/'))
45}
46
47// This extracts the main argument of a tool call. Tools with no known key fall back to their raw
48// JSON so the line is never blank, and whitespace is collapsed so a multi-line command can't break
49// the line apart.
50function subjectOf(input: unknown): string {
51  const fields = fieldsOf(input)
52  if (fields === undefined) return ''
53  const key = SUBJECT_KEYS.find(name => typeof fields[name] === 'string' && fields[name] !== '')
54  const raw =
55    key !== undefined ? String(fields[key]) : Object.keys(fields).length > 0 ? JSON.stringify(fields) : ''
56  return shortenSubject(raw.replace(/\s+/g, ' ').trim())
57}
58
59// Each probe surfaces one fact the hidden result block would have shown; add one here to extend the meta.
60// An errored call's output is the plain text the model read, so only an object carries result fields.
61type MetaProbe = (row: ToolFacts, result: Record<string, unknown> | undefined) => string | undefined
62
63const META_PROBES: MetaProbe[] = [
64  row => {
65    const status = statusOf(row)
66    return status === 'interrupted' || status === 'error' ? status : undefined
67  },
68  (row, result) => (result?.backgroundTaskId !== undefined ? 'background' : undefined),
69  (row, result) => (result?.timedOutAfterMs !== undefined ? 'timed out' : undefined),
70  (row, result) => {
71    const printed = [result?.stdout, result?.stderr]
72    return printed.some(stream => typeof stream === 'string' && stream.includes('Shell cwd was reset'))
73      ? 'shell cwd was reset'
74      : undefined
75  },
76  (row, result) => {
77    const note = result?.returnCodeInterpretation
78    return typeof note === 'string' && note !== '' ? note : undefined
79  },
80]
81
82// MARK: Line
83
84type ToolLine = { subject: string; meta: string }
85
86function clip(text: string, room: number): string {
87  if (text.length <= room) return text
88  return room <= 1 ? '…' : text.slice(0, room - 1) + '…'
89}
90
91// The subject is the only part cut, so the tool name and meta always stay readable.
92function lineFor(row: ToolFacts, columns: number): ToolLine {
93  // 2 is the dot's column, which the summary text sits beside.
94  const width = Math.max(columns - GUTTER - 2, 20)
95  const budget = Math.min(Math.max(width, MIN_CHARS), width * MAX_ROWS)
96  const result = fieldsOf(row.output)
97  const meta = META_PROBES.map(probe => probe(row, result))
98    .filter(note => note !== undefined)
99    .join(', ')
100  const frame = `tool_call: ${row.tool}()`.length + (meta === '' ? 0 : ` - ${meta}`.length)
101  return { subject: clip(subjectOf(row.input), budget - frame), meta }
102}
103
104// MARK: Status
105
106// The dot alone carries the call's state, so the line needs no "running" text. Theme keys follow
107// the person's theme: warning is its yellow, success its green, error its red.
108const DOT_COLORS: Record<Status, string> = { interrupted: 'error', error: 'error', running: 'warning', done: 'success' }
109
110const noteStyle = (isError: boolean) => (isError ? { color: 'error' } : { dimColor: true })
111
112// MARK: View
113
114// Tool rows don't say whether the ctrl+o transcript (or --verbose) is open, but the person's own
115// prompt rows do; their flag is mirrored here so every tool site leaves that view to the engine.
116let isExpandedView = false
117
118function isCompact(surface: string): boolean {
119  return surface === 'terminal' && !isExpandedView
120}
121
122// MARK: Detail pane
123
124const PANE = 'tool-detail'
125const detail = atom({ plugin: 'tool-lines', key: 'detail' } as const, null)
126
127// MARK: Hooks
128
129export const register: Register = on => {
130  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'composer' } } }, async ($, e, next) => {
131    if (e.surface === 'terminal' && e.props.isExpanded !== isExpandedView) {
132      isExpandedView = e.props.isExpanded
133      // Tool rows keep their last answer until asked again, so the switch has to redraw them.
134      $.ui.invalidate('ui.render')
135    }
136    if (!isCompact(e.surface)) return next(e)
137    const { Box } = $.ui.resolve(e)
138    return Box({ flexDirection: 'column', marginBottom: 1, children: [await next(e)] })
139  })
140
141  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
142    if (!isCompact(e.surface)) return next(e)
143    const { Box, Text, Button } = $.ui.resolve(e)
144    const line = lineFor(e.props, e.viewport?.columns ?? FALLBACK_COLUMNS)
145    const row: ToolDetail = {
146      id: e.props.tool_use_id,
147      tool: e.props.tool,
148      input: e.props.input,
149      output: e.props.output,
150      isRunning: e.props.isRunning,
151      isErrored: e.props.isErrored,
152      isInterrupted: e.props.isInterrupted,
153    }
154
155    // A second click on the call the pane already shows closes it.
156    const toggleDetail = async () => {
157      const shown = await update($, detail, current => (current?.id === row.id ? null : row))
158      if (shown === null) return $.ui.close({ id: PANE })
159      await $.ui.open({ id: PANE, title: `${row.tool} call` })
160    }
161
162    return Box({
163      flexDirection: 'row',
164      marginLeft: 2,
165      children: [
166        // The dot's own column keeps wrapped rows aligned under the text, as the engine's tool rows do.
167        Box({ minWidth: 2, flexShrink: 0, children: [Text({ color: DOT_COLORS[statusOf(e.props)], children: ['●'] })] }),
168        Box({
169          flexShrink: 1,
170          children: [
171            Button({
172              key: 'detail',
173              plain: true,
174              onPress: toggleDetail,
175              children: [
176                Text({
177                  wrap: 'wrap',
178                  children: [
179                    Text({ dimColor: true, children: ['tool_call: '] }),
180                    Text({ bold: true, children: [row.tool] }),
181                    `(${line.subject})`,
182                    ...(line.meta === '' ? [] : [Text({ ...noteStyle(row.isErrored), children: [` - ${line.meta}`] })]),
183                  ],
184                }),
185              ],
186            }),
187          ],
188        }),
189      ],
190    })
191  })
192
193  // Unfolding hands each grouped read or search to the ToolUse hook, so it flows like every other call.
194  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
195    if (!isCompact(e.surface)) return next(e)
196    return next({ ...e, props: { ...e.props, isExpanded: true } })
197  })
198
199  // The compact line already carries what the result block would say.
200  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
201    if (!isCompact(e.surface)) return next(e)
202    const { Box } = $.ui.resolve(e)
203    return Box({})
204  })
205
206  // A call opened while it ran gets its result once it lands.
207  on('tool.call', async ($, e, next) => {
208    const ran = await next(e)
209    const shown = await read($, detail)
210    if (shown?.id !== e.tool_use_id) return ran
211    const isErrored = ran.deny !== undefined || ran.isError === true
212    const output = ran.deny ?? (ran.isError ? ran.text : ran.result)
213    await update($, detail, current =>
214      current?.id === e.tool_use_id ? { ...current, isRunning: false, isErrored, output } : current,
215    )
216    return ran
217  }).catch(($, e, next) => next(e)) // The pane is a view: a failure here never touches the call.
218
219  // Closed by its close mark or Esc, the next click on the same line opens it again.
220  on('ui.close', { id: PANE }, async ($, e, next) => {
221    await update($, detail, () => null)
222    return next(e)
223  }).catch(($, e, next) => next(e)) // A failed clear never keeps the pane open.
224
225  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
226    const { Box, Text, Code } = $.ui.resolve(e)
227    const shown = await read($, detail)
228    if (shown === null) return Text({ dimColor: true, children: ['Click a tool line to see its call here.'] })
229    const status = statusOf(shown)
230    const sections = [...inputSections(shown), ...outputSections(shown)]
231
232    return Box({
233      flexDirection: 'column',
234      children: [
235        Text({
236          children: [
237            Text({ bold: true, children: [shown.tool] }),
238            Text({ ...noteStyle(status === 'error'), children: [` · ${status}`] }),
239          ],
240        }),
241        ...sections.flatMap(section => [
242          Box({ marginTop: 1, children: [Text({ dimColor: true, children: [section.label] })] }),
243          Code(section.code),
244        ]),
245      ],
246    })
247  })
248}
249
hooks/detail.ts 119 lines
1import type { BuiltinToolResults, CodeProps } from 'claude-code'
2
3import type { ToolFacts } from '../types'
4
5// MARK: Sections
6
7/** One labelled block of the detail pane, drawn as a `Code`. */
8export type Section = { label: string; code: CodeProps }
9
10type Hunk = BuiltinToolResults['Edit']['structuredPatch'][number]
11
12type Fields = Record<string, unknown>
13
14export const fieldsOf = (value: unknown): Fields | undefined =>
15  typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Fields) : undefined
16
17const json = (value: unknown): string => JSON.stringify(value, null, 2) ?? String(value)
18
19export type Status = 'interrupted' | 'error' | 'running' | 'done'
20
21export function statusOf(call: ToolFacts): Status {
22  if (call.isInterrupted) return 'interrupted'
23  if (call.isErrored) return 'error'
24  // isRunning is false while a call waits its turn or its approval, so only a stored result marks it done.
25  if (call.isRunning || call.output === undefined) return 'running'
26
27  return 'done'
28}
29
30// MARK: Input
31
32/** A Bash command reads best as shell; every other input as its JSON. */
33export function inputSections(call: ToolFacts): Section[] {
34  const fields = fieldsOf(call.input)
35  if (call.tool === 'Bash' && typeof fields?.command === 'string') {
36    const { command, ...rest } = fields
37    const sections: Section[] = [{ label: 'Command', code: { source: command, language: 'bash' } }]
38
39    return Object.keys(rest).length === 0
40      ? sections
41      : [...sections, { label: 'Options', code: { source: json(rest), language: 'json' } }]
42  }
43
44  return [{ label: 'Input', code: { source: json(call.input ?? {}), language: 'json' } }]
45}
46
47// MARK: Output
48
49function diffOf(hunks: Hunk[]): string {
50  return hunks
51    .map(hunk => [`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`, ...hunk.lines].join('\n'))
52    .join('\n')
53}
54
55const isHunkList = (value: unknown): value is Hunk[] =>
56  Array.isArray(value) && value.length > 0 && value.every(hunk => Array.isArray(fieldsOf(hunk)?.lines))
57
58/** Each probe reads one result shape; the first that answers wins. Add one here to cover a new tool. */
59type OutputProbe = (output: Fields) => Section[] | undefined
60
61const OUTPUT_PROBES: OutputProbe[] = [
62  // Bash: its two streams, each only when it printed something.
63  output => {
64    if (typeof output.stdout !== 'string' && typeof output.stderr !== 'string') return undefined
65    const streams: Section[] = []
66    if (output.stdout) streams.push({ label: 'stdout', code: { source: String(output.stdout) } })
67    if (output.stderr) streams.push({ label: 'stderr', code: { source: String(output.stderr) } })
68
69    return streams.length > 0 ? streams : [{ label: 'Output', code: { source: '(no output)' } }]
70  },
71  // Edit and Write: the change as a diff, or a new file's whole content.
72  output => {
73    if (isHunkList(output.structuredPatch)) {
74      return [{ label: 'Diff', code: { source: diffOf(output.structuredPatch), format: 'diff' } }]
75    }
76    if (output.type === 'create' && typeof output.content === 'string') {
77      return [{ label: 'Created', code: { source: output.content, path: String(output.filePath ?? '') } }]
78    }
79
80    return undefined
81  },
82  // Read: the file's lines, numbered from where the read started.
83  output => {
84    const file = fieldsOf(output.file)
85    if (typeof file?.content !== 'string') return undefined
86    const startLine = typeof file.startLine === 'number' ? file.startLine : 1
87
88    return [
89      {
90        label: 'File',
91        code: typeof file.filePath === 'string'
92          ? { source: file.content, path: file.filePath, startLine }
93          : { source: file.content, startLine },
94      },
95    ]
96  },
97  // Grep and Glob: matched lines, or the file list.
98  output => {
99    if (typeof output.content === 'string') return [{ label: 'Matches', code: { source: output.content } }]
100    if (Array.isArray(output.filenames)) return [{ label: 'Files', code: { source: output.filenames.join('\n') || '(none)' } }]
101
102    return undefined
103  },
104]
105
106export function outputSections(call: ToolFacts): Section[] {
107  const { output } = call
108  if (output === undefined) return []
109  // An errored, refused or interrupted call carries the text the model read.
110  if (typeof output === 'string') return [{ label: call.isErrored ? 'Error' : 'Output', code: { source: output } }]
111  const fields = fieldsOf(output) ?? {}
112  for (const probe of OUTPUT_PROBES) {
113    const sections = probe(fields)
114    if (sections !== undefined) return sections
115  }
116
117  return [{ label: 'Output', code: { source: json(output), language: 'json' } }]
118}
119
types/index.d.ts 20 lines
1/** The call the detail pane shows, as its row last carried it. */
2export type ToolDetail = {
3  id: string
4  tool: string
5  input: unknown
6  output?: unknown
7  isRunning: boolean
8  isErrored: boolean
9  isInterrupted: boolean
10}
11
12/** What a tool row says about its call, apart from its id. */
13export type ToolFacts = Omit<ToolDetail, 'id'>
14
15declare module 'claude-code' {
16  interface PluginState {
17    'tool-lines': { detail: ToolDetail | null }
18  }
19}
20