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

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
tool_call: Tool(subject) - meta. The dot is yellow while the call runs, green when it is done, and red when it fails.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.Clicks reach the mod in the fullscreen terminal.
/plugin marketplace add fgilio/claude-plugins
/plugin install tool-lines@fgilio
Then run /reload-plugins.
claude --plugin-dir .
claude plugin validate .
claude plugin test .
tool-lines in Tickloop/claude-mods, forked at commit 1042852.hooks/register.ts 249 lines1import { 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}
249hooks/detail.ts 119 lines1import 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}
119types/index.d.ts 20 lines1/** 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