Folds tool calls in the scrollback into one line per call, or one line per run of calls, with no result block

Claude Code mods I use daily.
rewrite-prompt: press Ctrl+P and the draft in the prompt box is rewritten in place by a model (Sonnet, on your plan). Nothing is sent until you press Enter, and Ctrl+_ undoes it.write-for-me: press Ctrl+N and the next prompt is written for you from the last 12 messages of the conversation. If the box has text, it is used as a hint ("tests too") and expanded. Nothing is sent, and Ctrl+_ undoes it.prompt-box: the prompts you type are drawn in the scrollback inside a thick cyan box instead of one > text line. Task notifications and messages from other sessions keep their normal rows. No key binding needed.tool-oneline: each tool call is drawn as one line (⏺ Bash(echo hello) (ctrl+o to expand)) with no result block under it. Set detail to runs for one line per run of calls (⏺ Ran 3 tools: Bash ×2, Read). Ctrl+O still shows the full rows. A failed call keeps its error text.claude plugin marketplace add ekinertac/claude-code-mods
claude plugin install rewrite-prompt@ekinertac-mods
claude plugin install write-for-me@ekinertac-mods
claude plugin install prompt-box@ekinertac-mods
claude plugin install tool-oneline@ekinertac-mods
Then bind the keys. Add these to the Global block of ~/.claude/keybindings.json and start a new session:
"ctrl+p": "app:toggleDiffNoiseFilter",
"ctrl+n": "app:toggleDiffPreSession"
To use the runs level, add this to ~/.claude/settings.json:
"pluginConfigs": { "tool-oneline": { "options": { "detail": "runs" } } }
No keybinding action runs a mod command. The mod draws a zero-size button above the prompt whose action is app:toggleDiffNoiseFilter (inert outside the diff panel; write-for-me uses app:toggleDiffPreSession the same way, so the two never share an action), and the chord you bind to that action presses it. Nothing shows on screen.
claude plugin test <mod> (stubs the model, no network).hooks/register.tsx 168 lines1// tool-oneline: folds tool calls in the scrollback into one line, and hides the result block.
2//
3// Why: a tool call normally takes a header, up to several result lines and a "+N lines" row, and a
4// long agent turn buries the conversation text in that output. The stored results and what the
5// model reads do not change.
6// Two levels, set by the `detail` option (plugin.json userConfig):
7// calls (default) one line per call: ⏺ Bash(echo hello) (ctrl+o to expand)
8// runs one line per run of calls: ⏺ Ran 3 tools: Bash ×2, Read (ctrl+o to expand)
9// A run is the calls made between two assistant messages that carry text. Rows after the first of
10// a run draw nothing, so the run costs one row and no blank lines.
11// How: ui.render hooks on ToolUse, ToolGroup and ToolResult. tool.call (main loop only) records the
12// runs in $.state, and the render hooks read them, so a growing run redraws its line.
13// ctrl+o: tool rows carry no "expanded" flag, so the flag is read from the UserMessage and
14// ToolGroup rows, which do, and a change invalidates ui.render. In the expanded transcript every
15// hook passes, and the engine draws full rows and results.
16// Constraint: a failed call keeps its error block, because a hidden failure is worse than a long one.
17// Related: ../types/index.d.ts is the $.state contract.
18
19import { atom, read, update } from 'claude-code'
20import type { Engine, Register } from 'claude-code'
21
22import type { Run } from '../types'
23
24const runs = atom({ plugin: 'tool-oneline', key: 'runs' } as const, [] as Run[])
25
26const HINT = ' (ctrl+o to expand)'
27
28// The one input field that says what a call is about, per tool; any other tool falls back to its
29// first string field.
30const KEYS: Record<string, string> = {
31 Bash: 'command',
32 Read: 'file_path',
33 Edit: 'file_path',
34 Write: 'file_path',
35 NotebookEdit: 'notebook_path',
36 Grep: 'pattern',
37 Glob: 'pattern',
38 WebFetch: 'url',
39 WebSearch: 'query',
40 Agent: 'description',
41 Task: 'description',
42}
43
44type Call = {
45 tool_use_id?: string
46 tool: string
47 input: unknown
48 isRunning: boolean
49 isErrored: boolean
50 isInterrupted: boolean
51}
52
53// Module variables start over on a reload, which is fine: they only hold the view flag and the
54// bookkeeping of the current turn. The runs themselves live in $.state.
55let detail = 'calls'
56let isExpanded = false
57let isNewTurn = true
58let lastCount = 0 // transcript length at the previous call: what came after it is what ended or kept the run
59
60function subject(tool: string, input: unknown): string {
61 const obj = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
62 const key = KEYS[tool]
63 const v = key ? obj[key] : Object.values(obj).find(x => typeof x === 'string')
64 // First line only: a heredoc or multi-line command must stay on one row.
65 return typeof v === 'string' ? v.split('\n')[0].trim() : ''
66}
67
68function summary(tools: string[]): string {
69 const counts = new Map<string, number>()
70 for (const t of tools) counts.set(t, (counts.get(t) ?? 0) + 1)
71 const parts = [...counts].map(([t, n]) => (n > 1 ? `${t} ×${n}` : t))
72 return `Ran ${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}: ${parts.join(', ')}`
73}
74
75// Declared at the top level because `claude plugin validate` only follows `$` into such functions.
76function sync($: Engine, value: boolean) {
77 if (isExpanded === value) return
78 isExpanded = value
79 // Tool rows cannot read the flag themselves, so they are asked to draw again.
80 $.ui.invalidate('ui.render')
81}
82
83function line(Box: any, Text: any, key: string, color: string, name: string, text: string) {
84 return (
85 <Box key={key} width="100%">
86 <Box flexShrink={0} marginRight={1}>
87 <Text color={color}>⏺</Text>
88 </Box>
89 <Box flexShrink={1}>
90 <Text wrap="truncate-end">
91 {name ? <Text bold>{name}</Text> : null}
92 {text}
93 </Text>
94 </Box>
95 <Box flexShrink={0}>
96 <Text dimColor>{HINT}</Text>
97 </Box>
98 </Box>
99 )
100}
101
102async function draw($: Engine, e: any, next: any, calls: Call[]) {
103 if (isExpanded) return next(e)
104 const { Box, Text } = $.ui.resolve(e)
105
106 if (detail === 'runs') {
107 const ids = calls.map(c => c.tool_use_id)
108 const run = (await read($, runs)).find(r => r.ids.some(id => ids.includes(id)))
109 if (run) {
110 return ids.includes(run.ids[0]) ? line(Box, Text, 'run', 'green', '', summary(run.tools)) : <Box display="none" />
111 }
112 // A call this module never saw (a resumed session, a reload): draw it as its own line.
113 }
114
115 const rows = calls.map((c, i) => {
116 const arg = subject(c.tool, c.input)
117 const color = c.isErrored || c.isInterrupted ? 'red' : c.isRunning ? 'yellow' : 'green'
118 return line(Box, Text, String(c.tool_use_id ?? i), color, c.tool, arg ? `(${arg})` : '')
119 })
120 return <Box flexDirection="column" width="100%">{rows}</Box>
121}
122
123export const register: Register = (on, options) => {
124 detail = options.detail === 'runs' ? 'runs' : 'calls'
125
126 on('turn.start', ($, e, next) => {
127 isNewTurn = true
128 return next(e)
129 })
130
131 on('tool.call', async ($, e, next) => {
132 if (e.agentId === undefined && e.tool_use_id) {
133 const id = e.tool_use_id
134 const messages = await $.session.messages()
135 // The transcript keeps each text block and each tool call as its own assistant message, so a
136 // run ends when any assistant text was written after the previous call.
137 const isTextBetween = messages.slice(lastCount).some(m => m.role === 'assistant' && m.text.trim() !== '')
138 lastCount = messages.length
139 const startsRun = isNewTurn || isTextBetween
140 isNewTurn = false
141 await update($, runs, list =>
142 startsRun || list.length === 0
143 ? [...list, { ids: [id], tools: [e.tool] }]
144 : list.map((r, i) => (i === list.length - 1 ? { ids: [...r.ids, id], tools: [...r.tools, e.tool] } : r)),
145 )
146 }
147 return next(e)
148 })
149
150 on('ui.render', { component: 'ToolUse' }, ($, e, next) => draw($, e, next, [e.props]))
151
152 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
153 sync($, e.props.isExpanded)
154 return draw($, e, next, [...e.props.calls])
155 })
156
157 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
158 if (isExpanded || e.props.isErrored) return next(e)
159 const { Box } = $.ui.resolve(e)
160 return <Box display="none" />
161 })
162
163 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
164 sync($, e.props.isExpanded)
165 return next(e)
166 })
167}
168types/index.d.ts 8 lines1export type Run = { ids: string[]; tools: string[] }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'tool-oneline': { runs: Run[] }
6 }
7}
8