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

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.
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.
| Mod | What it does | Command | |
|---|---|---|---|
next-lite | Suggests 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-stack | Routes 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-tools | Draws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line. | /lines | |
blast-radius | Holds 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-gate | Holds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers. | ||
replay-theater | Steps through the file edits Claude made in the last turn, one diff at a time. | /replay | |
usage-weather | A forecast band above the prompt: context fill, sparkline, rate limits, model and effort. | ||
prompt-git | Appends the git branch and working-tree counts to the hint line under the prompt. | ||
pixel-quest | Replaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls. | /toons | |
brief-mode | Appends 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-neon | Draws 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.
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.
hooks/register.ts 304 lines1import { 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}
304hooks/lines.ts 291 lines1// 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}
291types/index.d.ts 16 lines1export 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