SLOPSHOPPER

tool-lines

Draws each tool call as one compact 'tool_call: Tool(input) - meta' line and hides its result block

newrows
★ 73v0.1.0no licenseupdated 2026-10-08Tickloop/claude-mods/tool-lines
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-lines
⟨Claude Code's own drawing⟩ ⏺ Read(src/auth.ts) ⎿ Read 6 lines ● tool_call: Edit(/work/app/src/auth.ts) ⏺ 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

Your message
⟨Claude Code's own drawing⟩
Tool row
● tool_call: Edit(/work/app/src/auth.ts)
README

claude-mods

Make your claude code look and feel better. Easier to read so you miss fewer things.

ModWhat it does
glamour-darkDraws the model's text replies with glamour's default dark style
tool-linesDraws each tool call as one compact tool_call: Tool(input) - meta line and hides its result block

Installation

Add this repo as a plugin marketplace, then install the mods you want:

claude plugin marketplace add Tickloop/claude-mods
claude plugin install glamour-dark@claude-mods
claude plugin install tool-lines@claude-mods

Or do the same from inside a session with /plugin marketplace add Tickloop/claude-mods, then pick the mods from /plugin. Run /reload-plugins to apply them without restarting.

To pick up a new release (a mod's version in its plugin.json changed):

claude plugin marketplace update claude-mods
claude plugin update glamour-dark@claude-mods

Then run /reload-plugins in any open session to switch to the new version.

Development

Clone the repo, then start Claude Code from its root with both mods loaded:

claude --plugin-dir ./glamour-dark --plugin-dir ./tool-lines

--plugin-dir loads a mod for that session only. To load them every time, add an alias to your shell config (~/.zshrc or ~/.bashrc), pointing at where you cloned the repo:

alias claude='command claude --plugin-dir ~/path/to/claude-mods/glamour-dark --plugin-dir ~/path/to/claude-mods/tool-lines'
Source 1 files
hooks/register.ts 176 lines
1import type { Register } from 'claude-code'
2
3// MARK: Layout
4
5// Wide terminals fit the whole line on one row; narrow ones may wrap, up to MAX_ROWS.
6const MIN_CHARS = 120
7const MAX_ROWS = 3
8// A long argument (a whole script or prompt) buries the tool name, so the subject stops here, ellipsis included.
9const MAX_SUBJECT_CHARS = 35
10// The engine indents transcript rows, so a line sized to the full width would spill onto a second row.
11const GUTTER = 4
12// The viewport is absent until the terminal has been measured.
13const FALLBACK_COLUMNS = 120
14
15// MARK: Row facts
16
17type ToolRow = {
18  tool: string
19  input: unknown
20  output?: unknown
21  isRunning: boolean
22  isErrored: boolean
23  isInterrupted: boolean
24}
25
26// Input fields that name what a call acts on, most telling first; add a key here to cover a new tool.
27const SUBJECT_KEYS = [
28  'command',
29  'file_path',
30  'notebook_path',
31  'pattern',
32  'url',
33  'query',
34  'skill',
35  'description',
36  'path',
37  'action',
38  'prompt',
39]
40
41// This extracts the main argument of a tool call. Tools with no known key fall back to their raw
42// JSON so the line is never blank, and whitespace is collapsed so a multi-line command can't break
43// the line apart.
44function subjectOf(input: unknown): string {
45  if (typeof input !== 'object' || input === null) return ''
46  const fields = input as Record<string, unknown>
47  const key = SUBJECT_KEYS.find(name => typeof fields[name] === 'string' && fields[name] !== '')
48  const raw =
49    key !== undefined ? String(fields[key]) : Object.keys(fields).length > 0 ? JSON.stringify(fields) : ''
50  return raw.replace(/\s+/g, ' ').trim()
51}
52
53// An errored call's output is the plain text the model read, so only an object carries result fields.
54function resultField(row: ToolRow, name: string): unknown {
55  return typeof row.output === 'object' && row.output !== null
56    ? (row.output as Record<string, unknown>)[name]
57    : undefined
58}
59
60// Each probe surfaces one fact the hidden result block would have shown; add one here to extend the meta.
61type MetaProbe = (row: ToolRow) => string | undefined
62
63const META_PROBES: MetaProbe[] = [
64  row => (row.isInterrupted ? 'interrupted' : undefined),
65  row => (row.isErrored && !row.isInterrupted ? 'error' : undefined),
66  row => (resultField(row, 'backgroundTaskId') !== undefined ? 'background' : undefined),
67  row => (resultField(row, 'timedOutAfterMs') !== undefined ? 'timed out' : undefined),
68  row => {
69    const printed = `${resultField(row, 'stdout') ?? ''}\n${resultField(row, 'stderr') ?? ''}`
70    return /Shell cwd was reset/.test(printed) ? 'shell cwd was reset' : undefined
71  },
72  row => {
73    const note = resultField(row, 'returnCodeInterpretation')
74    return typeof note === 'string' && note !== '' ? note : undefined
75  },
76]
77
78// MARK: Line
79
80type ToolLine = { tool: string; subject: string; meta: string }
81
82function clip(text: string, room: number): string {
83  if (text.length <= room) return text
84  return room <= 1 ? '…' : text.slice(0, room - 1) + '…'
85}
86
87// The subject is the only part cut, so the tool name and meta always stay readable.
88function lineFor(row: ToolRow, columns: number): ToolLine {
89  // 2 is the dot's column, which the summary text sits beside.
90  const width = Math.max(columns - GUTTER - 2, 20)
91  const budget = Math.min(Math.max(width, MIN_CHARS), width * MAX_ROWS)
92  const meta = META_PROBES.map(probe => probe(row))
93    .filter(note => note !== undefined)
94    .join(', ')
95  const frame = `tool_call: ${row.tool}()`.length + (meta === '' ? 0 : ` - ${meta}`.length)
96  const subjectWidth = Math.min(MAX_SUBJECT_CHARS, budget - frame)
97  return { tool: row.tool, subject: clip(subjectOf(row.input), subjectWidth), meta }
98}
99
100// MARK: Status
101
102// The dot alone carries the call's state, so the line needs no "running" text. Theme keys follow
103// the person's theme: warning is its yellow, success its green, error its red.
104function dotStyle(row: ToolRow): { color: string } {
105  if (row.isErrored || row.isInterrupted) return { color: 'error' }
106  // isRunning is false while a call waits its turn or its approval, so only a stored result marks it done.
107  return row.isRunning || row.output === undefined ? { color: 'warning' } : { color: 'success' }
108}
109
110// MARK: View
111
112// Tool rows don't say whether the ctrl+o transcript (or --verbose) is open, but the person's own
113// prompt rows do; their flag is mirrored here so every tool site leaves that view to the engine.
114let isExpandedView = false
115
116function isCompact(surface: string): boolean {
117  return surface === 'terminal' && !isExpandedView
118}
119
120// MARK: Hooks
121
122export const register: Register = on => {
123  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'composer' } } }, async ($, e, next) => {
124    if (e.surface === 'terminal' && e.props.isExpanded !== isExpandedView) {
125      isExpandedView = e.props.isExpanded
126      // Tool rows keep their last answer until asked again, so the switch has to redraw them.
127      $.ui.invalidate('ui.render')
128    }
129    if (!isCompact(e.surface)) return next(e)
130    const { Box } = $.ui.resolve(e)
131    return Box({ flexDirection: 'column', marginBottom: 1, children: [await next(e)] })
132  })
133
134  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
135    if (!isCompact(e.surface)) return next(e)
136    const { Box, Text } = $.ui.resolve(e)
137    const line = lineFor(e.props, e.viewport?.columns ?? FALLBACK_COLUMNS)
138    const metaStyle = e.props.isErrored ? { color: 'error' } : { dimColor: true }
139    return Box({
140      flexDirection: 'row',
141      marginLeft: 2,
142      children: [
143        // The dot's own column keeps wrapped rows aligned under the text, as the engine's tool rows do.
144        Box({ minWidth: 2, flexShrink: 0, children: [Text({ ...dotStyle(e.props), children: ['●'] })] }),
145        Box({
146          flexShrink: 1,
147          children: [
148            Text({
149              wrap: 'wrap',
150              children: [
151                Text({ dimColor: true, children: ['tool_call: '] }),
152                Text({ bold: true, children: [line.tool] }),
153                `(${line.subject})`,
154                ...(line.meta === '' ? [] : [Text({ ...metaStyle, children: [` - ${line.meta}`] })]),
155              ],
156            }),
157          ],
158        }),
159      ],
160    })
161  })
162
163  // Unfolding hands each grouped read or search to the ToolUse hook, so it flows like every other call.
164  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
165    if (!isCompact(e.surface)) return next(e)
166    return next({ ...e, props: { ...e.props, isExpanded: true } })
167  })
168
169  // The compact line already carries what the result block would say.
170  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
171    if (!isCompact(e.surface)) return next(e)
172    const { Box } = $.ui.resolve(e)
173    return Box({})
174  })
175}
176