SLOPSHOPPER

tool-oneline

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

newrowsguard
v0.1.0no licenseupdated 2026-10-03ekinertac/claude-code-mods/tool-oneline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-oneline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Edit(/work/app/src/auth.ts) (ctrl+o to expand) ⏺ 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

Tool row
⏺ Edit(/work/app/src/auth.ts) (ctrl+o to expand)
README

claude-code-mods

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.

Setup

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" } } }

How the shortcut works

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.

Limits

  • Tested on Claude Code 2.1.287. Mod events and methods can change between releases.
  • The rewrite replaces the draft. It keeps the intent, language and concrete details, and adds no requirements.
  • Not checked in every terminal: if you see a blank row above the prompt, open an issue.
  • Tests: claude plugin test <mod> (stubs the model, no network).
Source 2 files
hooks/register.tsx 168 lines
1// 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}
168
types/index.d.ts 8 lines
1export type Run = { ids: string[]; tools: string[] }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'tool-oneline': { runs: Run[] }
6  }
7}
8