SLOPSHOPPER

tidy-tools

Draws each tool call as one compact line, or folds a turn into one 'N steps this turn' line; /lines cycles lines, turn, off.

newrowsguardcommand
v0.2.0no licenseupdated 2026-10-13fhva29/claude-code-mods/tidy-tools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tidy-tools
⟨Claude Code's own drawing⟩ ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 › /lines ⎿ tidy-tools: Compact tool lines: one row per turn (press it to open). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
⟨Claude Code's own drawing⟩
README

claude-code-mods

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.

Install

Requires a Claude Code build with mod (function hooks) support.

1. Add the marketplace. The repository is private, so gh auth login (or any git credential that can read it) must work first.

claude plugin marketplace add fhva29/claude-code-mods

Or clone it and add the folder; Claude Code then reads the mods straight from it, so an edit plus /reload-plugins is enough, with no reinstall:

git clone https://github.com/fhva29/claude-code-mods ~/.claude/mods
claude plugin marketplace add ~/.claude/mods

2. Install the mods you want (user scope):

claude plugin install next-lite@heitor-mods
claude plugin install tidy-tools@heitor-mods
# ...or all of them:
for m in blast-radius commit-gate model-stack next-lite pixel-quest prompt-git \
         replay-theater tidy-tools usage-weather brief-mode; do
  claude plugin install "$m@heitor-mods"
done

3. Reload inside Claude Code with /reload-plugins.

Options (userConfig) are set in /plugin → pick the mod → configure, then /reload-plugins. They are stored in ~/.claude/settings.json under pluginConfigs.

Mods

ModWhat it doesCommand
next-liteSuggests up to three next prompts above the input (press 1/2/3 to fill the prompt box, 0 to dismiss; the top one is also Tab ghost text). Sends a ~1–2k token digest of the turn to a small model instead of forking the whole session, so its cost does not grow with the conversation. Replacement for next-steps. Options: mode (auto/manual), model, minAnswerChars, suggestSkills./next
model-stackRoutes the model and effort of the main chat turn and of each subagent with TypeSafe's Jev, and shows the roster, decisions and agent graph in a pane. Needs TYPESAFE_API_KEY in the environment./stack
tidy-toolsDraws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line./lines
blast-radiusHolds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons.
commit-gateHolds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers.
replay-theaterSteps through the file edits Claude made in the last turn, one diff at a time./replay
usage-weatherA forecast band above the prompt: context fill, sparkline, rate limits, model and effort.
prompt-gitAppends the git branch and working-tree counts to the hint line under the prompt.
pixel-questReplaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls./toons
brief-modeAppends a short-answer style section to the system prompt: answer or next action first, numbered steps, lists capped at five, no preamble, recap or closers. Edit hooks/rules.ts to tune it.
midnight-neonDraws assistant replies with a dark neon palette: filled h1 banner, colored h2–h6, colored bullets, quote bars, highlighted bold, italic, inline code and links. Fences and tables keep the engine's renderer. Not installed by default; /neon flips it per session, `claude plugin enable\disable` flips it for good./neon

_archive holds mods that are kept but not in use (branch-hud); they are not listed in the marketplace.

Development

Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module (register.ts or register.tsx). Check one with:

claude plugin validate <mod>
claude plugin test <mod>

claude plugin test does not type-check; run tsc against the SDK declarations Claude Code writes to <mod>/.claude-plugin/types/ as well.

next-lite builds on next-steps (MIT, Thariq Shihipar): it reuses the text sanitizer and the reply parsing, and replaces the session fork with a small digest.

Source 3 files
hooks/register.ts 304 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5  clipLines,
6  diffOf,
7  dotColor,
8  lineFor,
9  MAX_OUTPUT_LINES,
10  outputTextOf,
11  ROW_INDENT,
12} from './lines'
13import type { Tone } from './lines'
14
15const COMMAND = 'lines'
16const ENABLED_KEY = 'enabled'
17const MODE_KEY = 'mode'
18// `lines` draws one compact row per call; `turn` folds a whole turn into one openable row.
19const MODES = ['lines', 'turn', 'off'] as const
20type Mode = (typeof MODES)[number]
21// Enough to cover every row a transcript keeps drawn; older rows simply lose their duration.
22const MAX_TIMINGS = 300
23// The viewport is absent until the terminal has been measured.
24const FALLBACK_COLUMNS = 120
25
26// The theme has no neutral panel color, so the opened output uses a fixed dark panel.
27const OUTPUT_BACKGROUND = '#262b33'
28const OUTPUT_FOREGROUND = '#c9d1d9'
29
30// Theme keys, so the colors follow the person's theme.
31const TONE_STYLE: Record<Tone, { dimColor?: boolean; color?: string }> = {
32  dim: { dimColor: true },
33  added: { color: 'success' },
34  removed: { color: 'error' },
35}
36
37const timings = atom({ plugin: 'tidy-tools', key: 'timings' } as const, [])
38// tool_use_ids whose output the person opened by clicking the tool name.
39const expanded = atom({ plugin: 'tidy-tools', key: 'expanded' } as const, [])
40// Which turn each main-loop call ran in, in call order, so a turn's rows can tell who comes first.
41const turnOf = atom({ plugin: 'tidy-tools', key: 'turnOf' } as const, [])
42// turnIds whose calls the person opened out of the one-line summary.
43const openTurns = atom({ plugin: 'tidy-tools', key: 'openTurns' } as const, [])
44// tool_use_ids of main-loop calls that errored, were refused or threw.
45const failed = atom({ plugin: 'tidy-tools', key: 'failed' } as const, [])
46const currentTurn = atom({ plugin: 'tidy-tools', key: 'currentTurn' } as const, '')
47
48// Module variables start over on a reload; session.start fires again and restores them.
49let mode: Mode = 'lines'
50let sessionCwd = ''
51// Tool rows don't say whether the ctrl+o transcript (or --verbose) is open, but the person's own
52// prompt rows do; their flag is mirrored here so every tool site leaves that view to the engine.
53let isExpandedView = false
54
55function isCompact(surface: string): boolean {
56  return mode !== 'off' && surface === 'terminal' && !isExpandedView
57}
58
59async function recordTiming($: EngineInterface, id: string, ms: number): Promise<void> {
60  await update($, timings, list => [...list.filter(one => one.id !== id), { id, ms }].slice(-MAX_TIMINGS))
61}
62
63async function durationOf($: EngineInterface, id: string): Promise<number | undefined> {
64  const list = await read($, timings)
65  return list.find(one => one.id === id)?.ms
66}
67
68function parseMode(value: unknown): Mode | undefined {
69  return MODES.find(one => one === value)
70}
71
72// Older versions saved a boolean under `enabled`; honour it until a mode has been chosen.
73async function loadMode($: EngineInterface): Promise<Mode> {
74  const saved = parseMode(await $.store.get(MODE_KEY))
75  if (saved !== undefined) return saved
76  return (await $.store.get(ENABLED_KEY)) === false ? 'off' : 'lines'
77}
78
79const MODE_TEXT: Record<Mode, string> = {
80  lines: 'Compact tool lines: one row per call.',
81  turn: 'Compact tool lines: one row per turn (press it to open).',
82  off: 'Compact tool lines off.',
83}
84
85async function isTurnOpen($: EngineInterface, id: string): Promise<boolean> {
86  const turn = await turnFor($, id)
87  return (await read($, openTurns)).includes(turn.id)
88}
89
90async function turnFor($: EngineInterface, id: string): Promise<{ id: string }> {
91  const known = (await read($, turnOf)).find(one => one.id === id)
92  return { id: known?.turn ?? (await read($, currentTurn)) }
93}
94
95export const register: Register = on => {
96  on('session.start', async ($, e, next) => {
97    sessionCwd = e.cwd
98    mode = await loadMode($)
99    await $.command.register({
100      name: COMMAND,
101      description: 'Cycle tool rows: lines, turn, off (or /lines <mode>)',
102    })
103    return next(e)
104  })
105
106  on('command.run', { command: COMMAND }, async ($, e) => {
107    const asked = parseMode(e.args.trim())
108    if (e.args.trim() !== '' && asked === undefined) {
109      return { text: `Unknown mode "${e.args.trim()}". Use: ${MODES.join(', ')}.` }
110    }
111    mode = asked ?? MODES[(MODES.indexOf(mode) + 1) % MODES.length]
112    await $.store.set(MODE_KEY, mode)
113    $.ui.invalidate('ui.render')
114    return { text: MODE_TEXT[mode] }
115  })
116
117  on('turn.start', async ($, e, next) => {
118    await update($, currentTurn, () => e.turnId)
119    return next(e)
120  }).catch(($, e, next) => next(e))
121
122  on('tool.call', async ($, e, next) => {
123    const startedAt = await $.clock.now()
124    if (e.agentId === undefined) {
125      const turn = await read($, currentTurn)
126      await update($, turnOf, list =>
127        [...list.filter(one => one.id !== e.tool_use_id), { id: e.tool_use_id, turn }].slice(-MAX_TIMINGS),
128      )
129    }
130    let isFailed = false
131    try {
132      const result = await next(e)
133      isFailed = result.deny !== undefined || result.isError === true
134      return result
135    } catch (error) {
136      isFailed = true
137      throw error
138    } finally {
139      // Recorded before the timing, which is what redraws the summary as finished.
140      if (isFailed && e.agentId === undefined) {
141        await update($, failed, list => [...list.filter(id => id !== e.tool_use_id), e.tool_use_id].slice(-MAX_TIMINGS))
142      }
143      await recordTiming($, e.tool_use_id, (await $.clock.now()) - startedAt)
144    }
145  }).catch(($, e, next) => next(e))
146
147  on(
148    'ui.render',
149    { component: 'UserMessage', props: { origin: { kind: 'composer' } } },
150    async ($, e, next) => {
151      if (e.surface === 'terminal' && e.props.isExpanded !== isExpandedView) {
152        isExpandedView = e.props.isExpanded
153        // Tool rows keep their last answer until asked again, so the switch has to redraw them.
154        $.ui.invalidate('ui.render')
155      }
156      if (!isCompact(e.surface)) return next(e)
157      // Compact rows carry no margins of their own, so the prompt keeps a blank line off the calls below it.
158      const { Box } = $.ui.resolve(e)
159      return Box({ flexDirection: 'column', marginBottom: 1, children: [await next(e)] })
160    },
161  )
162
163  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
164    if (!isCompact(e.surface)) return next(e)
165    const { Box, Text, Button, Code } = $.ui.resolve(e)
166    const compact = async () => {
167      const line = lineFor(e.props, {
168        columns: e.viewport?.columns ?? FALLBACK_COLUMNS,
169        cwd: sessionCwd,
170        durationMs: await durationOf($, e.props.tool_use_id),
171      })
172      const id = e.props.tool_use_id
173      const diff = diffOf(e.props)
174      const output = diff === undefined ? outputTextOf(e.props) : undefined
175      const hasDetail = diff !== undefined || output !== undefined
176      const isOpen = hasDetail && (await read($, expanded)).includes(id)
177      const filePath = (e.props.input as { file_path?: unknown } | null)?.file_path
178      const toggle = (): void => {
179        void update($, expanded, list =>
180          list.includes(id) ? list.filter(one => one !== id) : [...list, id],
181        )
182      }
183      const row = Box({
184        flexDirection: 'row',
185        marginLeft: ROW_INDENT,
186        children: [
187          Box({
188            minWidth: 2,
189            flexShrink: 0,
190            children: [Text({ color: dotColor(e.props), children: ['●'] })],
191          }),
192          // Only a finished call with output has something to open.
193          !hasDetail
194            ? Text({ bold: true, children: [line.tool] })
195            : Button({ key: 'toggle', label: line.tool, plain: true, onPress: toggle }),
196          Text({
197            wrap: 'truncate-end',
198            children: [
199              '(',
200              ...(line.subjectDir === '' ? [] : [Text({ dimColor: true, children: [line.subjectDir] })]),
201              line.subjectName,
202              ')',
203              ...line.tail.map(span => Text({ ...TONE_STYLE[span.tone], children: [span.text] })),
204              ...(line.status === ''
205                ? []
206                : [Text({ color: 'error', children: [` - ${line.status}`] })]),
207            ],
208          }),
209        ],
210      })
211      if (!isOpen) return row
212      if (diff !== undefined) {
213        return Box({
214          flexDirection: 'column',
215          children: [
216            row,
217            Box({
218              marginLeft: ROW_INDENT + 2,
219              flexDirection: 'column',
220              children: [
221                Code({
222                  source: diff.source,
223                  format: 'diff',
224                  path: typeof filePath === 'string' ? filePath : undefined,
225                }),
226                ...(diff.hiddenLines === 0
227                  ? []
228                  : [Text({ dimColor: true, children: [`… ${diff.hiddenLines} more lines`] })]),
229              ],
230            }),
231          ],
232        })
233      }
234      return Box({
235        flexDirection: 'column',
236        children: [
237          row,
238          Box({
239            marginLeft: ROW_INDENT + 2,
240            paddingX: 1,
241            backgroundColor: OUTPUT_BACKGROUND,
242            children: [
243              Text({ color: OUTPUT_FOREGROUND, children: [clipLines(output ?? '', MAX_OUTPUT_LINES)] }),
244            ],
245          }),
246        ],
247      })
248    }
249    if (mode !== 'turn') return compact()
250    const turn = await turnFor($, e.props.tool_use_id)
251    const members = (await read($, turnOf)).filter(one => one.turn === turn.id)
252    const isFirst = (members[0]?.id ?? e.props.tool_use_id) === e.props.tool_use_id
253    const isOpen = (await read($, openTurns)).includes(turn.id)
254    if (!isFirst) return isOpen ? compact() : Box({})
255    const finished = await read($, timings)
256    const pending = members.filter(one => !finished.some(done => done.id === one.id)).length
257    const count = Math.max(members.length, 1)
258    const failedIds = await read($, failed)
259    const failures = members.filter(one => failedIds.includes(one.id)).length
260    const toggle = (): void => {
261      void update($, openTurns, list =>
262        list.includes(turn.id) ? list.filter(one => one !== turn.id) : [...list, turn.id],
263      )
264    }
265    const summary = Box({
266      flexDirection: 'row',
267      marginLeft: ROW_INDENT,
268      children: [
269        Box({
270          minWidth: 2,
271          flexShrink: 0,
272          children: [Text({ color: pending > 0 ? 'warning' : failures > 0 ? 'error' : undefined, dimColor: pending === 0 && failures === 0, children: [pending > 0 || failures > 0 ? '●' : '○'] })],
273        }),
274        Button({
275          key: 'turn-toggle',
276          label: `${count} ${count === 1 ? 'step' : 'steps'} this turn ${isOpen ? '⌄' : '›'}`,
277          plain: true,
278          onPress: toggle,
279        }),
280        ...(failures === 0 ? [] : [Text({ color: 'error', children: [`  ✗ ${failures} failed`] })]),
281      ],
282    })
283    if (!isOpen) return summary
284    return Box({ flexDirection: 'column', children: [summary, await compact()] })
285  })
286
287  // Unfolding hands each grouped read or search to the ToolUse hook, so it gets its own line.
288  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
289    if (!isCompact(e.surface)) return next(e)
290    return next({ ...e, props: { ...e.props, isExpanded: true } })
291  })
292
293  // The compact line already carries what a successful result block would say; errors stay visible.
294  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
295    if (!isCompact(e.surface)) return next(e)
296    // Folded in a turn summary, an error stays out of sight with the rest until the turn is opened.
297    if (e.props.isErrored && (mode !== 'turn' || (await isTurnOpen($, e.props.tool_use_id)))) {
298      return next(e)
299    }
300    const { Box } = $.ui.resolve(e)
301    return Box({})
302  })
303}
304
hooks/lines.ts 291 lines
1// Pure helpers behind the compact tool line; nothing here touches `$`.
2
3export type ToolRow = {
4  tool: string
5  input: unknown
6  output?: unknown
7  isRunning: boolean
8  isErrored: boolean
9  isInterrupted: boolean
10}
11
12export type Tone = 'dim' | 'added' | 'removed'
13export type Span = { text: string; tone: Tone }
14
15export type ToolLine = {
16  tool: string
17  // A path subject is split so its folders can be drawn faint and its file name plain.
18  subjectDir: string
19  subjectName: string
20  tail: Span[]
21  status: string
22}
23
24// The engine indents transcript rows, so a line sized to the full width would spill onto a second row.
25const GUTTER = 4
26// The row's own left margin, which lines it up with the reply text.
27export const ROW_INDENT = 2
28// The dot's own column plus the space after it.
29const DOT_WIDTH = 2
30const MIN_WIDTH = 20
31// A capped subject keeps durations and summaries in roughly one column, so the list scans vertically.
32const MAX_SUBJECT_CHARS = 50
33
34// Input fields that name what a call acts on, most telling first.
35const SUBJECT_KEYS = [
36  'command',
37  'file_path',
38  'notebook_path',
39  'pattern',
40  'url',
41  'query',
42  'skill',
43  'description',
44  'path',
45  'action',
46  'prompt',
47]
48const PATH_KEYS = new Set(['file_path', 'notebook_path', 'path'])
49
50type Subject = { text: string; isPath: boolean }
51
52function pickSubject(input: unknown, cwd: string): Subject {
53  if (typeof input !== 'object' || input === null) return { text: '', isPath: false }
54  const fields = input as Record<string, unknown>
55  const key = SUBJECT_KEYS.find(name => typeof fields[name] === 'string' && fields[name] !== '')
56  const raw =
57    key !== undefined
58      ? String(fields[key])
59      : Object.keys(fields).length > 0
60        ? JSON.stringify(fields)
61        : ''
62  return {
63    text: relativeTo(raw.replace(/\s+/g, ' ').trim(), cwd),
64    isPath: key !== undefined && PATH_KEYS.has(key),
65  }
66}
67
68export function subjectOf(input: unknown, cwd = ''): string {
69  return pickSubject(input, cwd).text
70}
71
72function relativeTo(text: string, cwd: string): string {
73  if (cwd === '' || cwd === '/') return text
74  const root = cwd.endsWith('/') ? cwd : `${cwd}/`
75  return text.split(root).join('')
76}
77
78export function clip(text: string, room: number): string {
79  if (text.length <= room) return text
80  return room <= 1 ? '…' : `${text.slice(0, room - 1)}…`
81}
82
83// A path is cut from its start, so the file name survives.
84export function clipStart(text: string, room: number): string {
85  if (text.length <= room) return text
86  return room <= 1 ? '…' : `…${text.slice(text.length - room + 1)}`
87}
88
89export function formatDuration(ms: number): string {
90  const seconds = Math.max(ms, 0) / 1000
91  if (seconds < 60) return `${seconds.toFixed(1)}s`
92  const whole = Math.floor(seconds)
93  return `${Math.floor(whole / 60)}m ${whole % 60}s`
94}
95
96function plural(count: number, noun: string): string {
97  return `${count} ${noun}${count === 1 ? '' : 's'}`
98}
99
100function countLines(text: unknown): number {
101  if (typeof text !== 'string' || text.trim() === '') return 0
102  return text.replace(/\n+$/, '').split('\n').length
103}
104
105// An errored call's output is the plain text the model read, so only an object carries result fields.
106function fieldsOf(output: unknown): Record<string, unknown> | undefined {
107  return typeof output === 'object' && output !== null
108    ? (output as Record<string, unknown>)
109    : undefined
110}
111
112function dim(text: string): Span[] {
113  return [{ text, tone: 'dim' }]
114}
115
116function diffStat(patch: unknown): Span[] | undefined {
117  if (!Array.isArray(patch)) return undefined
118  let added = 0
119  let removed = 0
120  for (const hunk of patch) {
121    const lines = fieldsOf(hunk)?.lines
122    if (!Array.isArray(lines)) continue
123    for (const line of lines) {
124      if (typeof line !== 'string') continue
125      if (line.startsWith('+')) added += 1
126      else if (line.startsWith('-')) removed += 1
127    }
128  }
129  return [
130    { text: `+${added}`, tone: 'added' },
131    { text: ' ', tone: 'dim' },
132    { text: `−${removed}`, tone: 'removed' },
133  ]
134}
135
136function formatKilobytes(bytes: number): string {
137  const kb = bytes / 1024
138  return kb < 10 ? kb.toFixed(1) : String(Math.round(kb))
139}
140
141export function summaryOf(row: ToolRow): Span[] | undefined {
142  if (row.isErrored || row.isInterrupted) return undefined
143  const out = fieldsOf(row.output)
144  if (out === undefined) return undefined
145  switch (row.tool) {
146    case 'Write':
147      if (out.type === 'create') return dim(`new · ${plural(countLines(out.content), 'line')}`)
148      return diffStat(out.structuredPatch)
149    case 'Edit':
150    case 'NotebookEdit':
151      return diffStat(out.structuredPatch)
152    case 'Read': {
153      const lines = fieldsOf(out.file)?.numLines
154      return typeof lines === 'number' ? dim(plural(lines, 'line')) : undefined
155    }
156    case 'Bash': {
157      const note = out.returnCodeInterpretation
158      if (typeof note === 'string' && note !== '') return dim(note)
159      return dim(`${plural(countLines(out.stdout) + countLines(out.stderr), 'line')} out`)
160    }
161    case 'WebFetch':
162      if (typeof out.code !== 'number' || typeof out.bytes !== 'number') return undefined
163      return dim(`${out.code} · ${formatKilobytes(out.bytes)} KB`)
164    default:
165      return undefined
166  }
167}
168
169export function textOf(spans: readonly Span[] | undefined): string {
170  return (spans ?? []).map(span => span.text).join('')
171}
172
173export function statusOf(row: ToolRow): string {
174  const out = fieldsOf(row.output)
175  const notes = [
176    row.isInterrupted ? 'interrupted' : undefined,
177    out?.timedOutAfterMs !== undefined ? 'timed out' : undefined,
178    row.isErrored && !row.isInterrupted ? 'error' : undefined,
179    out?.backgroundTaskId !== undefined ? 'background' : undefined,
180  ]
181  return notes.filter(note => note !== undefined).join(', ')
182}
183
184export type DotColor = 'warning' | 'success' | 'error'
185
186export function dotColor(row: ToolRow): DotColor {
187  if (row.isErrored || row.isInterrupted) return 'error'
188  // isRunning is false while a call waits its turn or its approval, so only a stored result marks it done.
189  return row.isRunning || row.output === undefined ? 'warning' : 'success'
190}
191
192function tailOf(row: ToolRow, durationMs: number | undefined): Span[] {
193  const parts: Span[][] = []
194  if (durationMs !== undefined) parts.push(dim(formatDuration(durationMs)))
195  const summary = summaryOf(row)
196  if (summary !== undefined) parts.push(summary)
197  if (parts.length === 0) return []
198  const joined: Span[] = [{ text: '  ', tone: 'dim' }]
199  parts.forEach((part, index) => {
200    if (index > 0) joined.push({ text: ' · ', tone: 'dim' })
201    joined.push(...part)
202  })
203  return joined
204}
205
206function splitPath(path: string): { dir: string; name: string } {
207  const cut = path.lastIndexOf('/')
208  return cut < 0 ? { dir: '', name: path } : { dir: path.slice(0, cut + 1), name: path.slice(cut + 1) }
209}
210
211// The subject is the only part cut, so the tool name, duration and summary always stay readable.
212export function lineFor(
213  row: ToolRow,
214  options: { columns: number; cwd: string; durationMs: number | undefined },
215): ToolLine {
216  const tail = tailOf(row, options.durationMs)
217  const status = statusOf(row)
218  const width = Math.max(options.columns - GUTTER - ROW_INDENT - DOT_WIDTH, MIN_WIDTH)
219  const frame =
220    `${row.tool}()`.length + textOf(tail).length + (status === '' ? 0 : ` - ${status}`.length)
221  const room = Math.max(Math.min(MAX_SUBJECT_CHARS, width - frame), 1)
222  const subject = pickSubject(row.input, options.cwd)
223  if (subject.isPath) {
224    const { dir, name } = splitPath(clipStart(subject.text, room))
225    return { tool: row.tool, subjectDir: dir, subjectName: name, tail, status }
226  }
227  return { tool: row.tool, subjectDir: '', subjectName: clip(subject.text, room), tail, status }
228}
229
230// Lines of output an expanded row shows; the rest is summarized so one call cannot flood the transcript.
231export const MAX_OUTPUT_LINES = 15
232
233// The plain text a person would read for a finished call; undefined when there is nothing to show.
234export function outputTextOf(row: ToolRow): string | undefined {
235  if (row.output === undefined || row.isErrored) return undefined
236  const out = fieldsOf(row.output)
237  let text: string
238  if (typeof row.output === 'string') {
239    text = row.output
240  } else if (out === undefined) {
241    return undefined
242  } else if (row.tool === 'Bash') {
243    text = [out.stdout, out.stderr].filter(part => typeof part === 'string' && part !== '').join('\n')
244  } else if (typeof out.content === 'string') {
245    text = out.content
246  } else if (typeof fieldsOf(out.file)?.content === 'string') {
247    text = String(fieldsOf(out.file)?.content)
248  } else {
249    text = JSON.stringify(out, null, 2)
250  }
251  text = text.replace(/\n+$/, '')
252  return text === '' ? undefined : text
253}
254
255export function clipLines(text: string, maxLines: number): string {
256  const lines = text.split('\n')
257  if (lines.length <= maxLines) return text
258  return [...lines.slice(0, maxLines), `… ${lines.length - maxLines} more lines`].join('\n')
259}
260
261// Diff lines an expanded edit shows before the rest is summarized.
262export const MAX_DIFF_LINES = 60
263
264export type DiffView = { source: string; hiddenLines: number }
265
266// Rebuilds a unified diff from the structured patch an edit stores, cut to `maxLines` body lines.
267// A cut hunk gets its header counts recomputed, since the diff view only parses consistent hunks.
268export function diffOf(row: ToolRow, maxLines = MAX_DIFF_LINES): DiffView | undefined {
269  if (row.isErrored || row.isInterrupted) return undefined
270  const patch = fieldsOf(row.output)?.structuredPatch
271  if (!Array.isArray(patch)) return undefined
272  let budget = maxLines
273  let hiddenLines = 0
274  const chunks: string[] = []
275  for (const raw of patch) {
276    const hunk = fieldsOf(raw)
277    if (hunk === undefined || !Array.isArray(hunk.lines)) continue
278    const lines = hunk.lines.filter((line): line is string => typeof line === 'string')
279    const kept = lines.slice(0, Math.max(budget, 0))
280    hiddenLines += lines.length - kept.length
281    budget -= kept.length
282    if (kept.length === 0) continue
283    const oldCount = kept.filter(line => !line.startsWith('+')).length
284    const newCount = kept.filter(line => !line.startsWith('-')).length
285    const oldStart = typeof hunk.oldStart === 'number' ? hunk.oldStart : 1
286    const newStart = typeof hunk.newStart === 'number' ? hunk.newStart : 1
287    chunks.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n${kept.join('\n')}`)
288  }
289  return chunks.length === 0 ? undefined : { source: chunks.join('\n'), hiddenLines }
290}
291
types/index.d.ts 16 lines
1export type ToolTiming = { id: string; ms: number }
2export type ToolTurn = { id: string; turn: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'tidy-tools': {
7      timings: ToolTiming[]
8      expanded: string[]
9      turnOf: ToolTurn[]
10      openTurns: string[]
11      failed: string[]
12      currentTurn: string
13    }
14  }
15}
16