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

Make your claude code look and feel better. Easier to read so you miss fewer things.
| Mod | What it does |
|---|---|
glamour-dark | Draws the model's text replies with glamour's default dark style |
tool-lines | Draws each tool call as one compact tool_call: Tool(input) - meta line and hides its result block |
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.
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'hooks/register.ts 176 lines1import 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