Long shell output, folded before Claude reads it. Errors, the start and the end stay; you see about how many tokens that saved.

<img src="../../brand/pixel-icons/svg/trim.svg" width="64" height="64" alt="">
Long shell output, folded before Claude reads it. Errors, the start and the end stay; the rest folds, and you see about how many tokens that saved.
✓ src/app.test.ts (14 tests)
...
[... 1,840 lines folded ...]
src/api.ts(41,7): error TS2345: Argument of type 'string' is not assignable
...
Tests 2 failed | 312 passed (314)
[trim: 1,892 of 2,000 lines folded, about 11,000 tokens saved. The folded lines were not sent; ...]
trim: 1,892 lines folded · ~11k tokens saved [expand]
/plugin install trim --marketplace modmyclaude/claude-code-mods
Nothing to set up. When a Bash command prints more than 120 lines or 6,000 characters, Claude reads a folded copy:
✕, ×, ●, ✗, ✘), and every line your own pattern names. A failed test stays even when the command itself exits cleanly (piped into tee, or after || true).[... N lines folded ...] line per gap. An error line repeated word for word is kept once with how many times it came (warning: slow test [3 times]); two errors at different places are always two lines. A run of lines that differ only in a percentage or a counter (a progress bar) folds to its first line with a count.grep, sed -n or tail; for one that changes anything (an install, a migration, a deploy) it is told never to repeat it, and to ask you instead, since you can see the whole output. Claude never mistakes the folded copy for the whole output.You still see everything:
trim ~34k saved.| Command | What it does |
|---|---|
/trim | A pane with every folded call this session (newest first), its lines and savings, and a total |
/trim show <n> | The full output of call n in a pane of its own |
/trim off | Pause it for this session (only when you type it) |
/trim on | Resume it |
"Only when you type it" means typed by you in the terminal, the desktop app or Remote Control. A program you run that drives Claude Code through its SDK counts as you too, since the desktop app sends that way; another mod, a peer session or Claude running the command is refused.
Every token figure is an estimate (about four characters per token) and says "about" or ~.
Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.
/trim says it is off. /config or the modmyclaude hub flips it, live; the flip reloads the mod, so the full outputs it kept are gone. (/trim off only pauses it for this session.)| between several, such as deploy id|migrated) whose lines never fold. A pattern that does not compile is ignored./trim panes. The panes and the expand never reach Claude; /trim answers with no text for the model.This mod was built from the Trim prompt.
More mods: the full catalogue.
hooks/register.tsx 344 lines1// trim: long shell output, folded before Claude reads it. A Bash result past the
2// line or character limit keeps its head, its tail and every error-like line;
3// the rest folds, and one footer line tells Claude what was folded and how to
4// read a part again. stderr, failed commands and short output are never touched.
5//
6// The model reads what a tool.call hook answers: a fresh `{ result }` is mapped
7// with Bash's own mapper (never the object next(e) gave, whose messages core
8// would reuse). The person can still see everything: the row under the call
9// says what was saved and expands, and /trim lists the calls and shows each in
10// full, in panes Claude never reads. Full outputs live in this module's memory.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { TrimCall, TrimLog } from '../types'
16import { aboutTokens, clean, grouped, oneLine, readSettings, rowLine, shortTokens, statusLine, trimOutput } from './logic'
17import type { Settings } from './logic'
18
19const LOG = { plugin: 'trim', key: 'log' } as const
20const OFF = { plugin: 'trim', key: 'isOff' } as const
21const SHOWN = { plugin: 'trim', key: 'shown' } as const
22
23const EMPTY: TrimLog = { calls: [], count: 0, saved: 0 }
24const log = atom(LOG, EMPTY)
25const isOff = atom(OFF, false)
26const shown = atom(SHOWN, 0)
27
28const LIST_PANE = 'trim'
29const OUTPUT_PANE = 'trim-output'
30/** Full outputs kept for showing: the last 20 calls, two million characters in all. */
31export const KEEP_CALLS = 20
32export const KEEP_CHARS = 2_000_000
33/** The most of one output the pane draws at once. */
34const SHOW_CHARS = 90_000
35/** Origins that are the person: typed, the phone bridge, the desktop app's box. */
36const PERSON = new Set(['composer', 'bridge', 'sdk'])
37const USAGE = '/trim lists the folded calls; /trim show <n> shows one in full; /trim off pauses it; /trim on resumes it.'
38
39/** Bash's result record, the fields trim reads or must leave alone. */
40type BashRecord = {
41 stdout: string
42 stderr: string
43 interrupted: boolean
44 isImage?: boolean
45 backgroundTaskId?: string
46 persistedOutputPath?: string
47 structuredContent?: unknown[]
48}
49
50// What lives only as long as this copy of the module: the settings, the full
51// outputs (by tool call id, oldest first) and the rows the person expanded.
52let settings: Settings = readSettings({})
53const fulls = new Map<string, string>()
54const expanded = new Set<string>()
55
56// The switch (`enabled`, which /config and the modmyclaude hub flip): off, trim folds
57// nothing, keeps nothing and draws nothing; Claude reads every output as Bash made it.
58// The engine reloads the mod with the new value; the config.set hook below is the same
59// switch at once. /trim off is another thing: a pause for this session only.
60let isOn = true
61const OFF_TEXT = 'Trim is off. /mods or /config turns it on.'
62
63/** Keeps one full output, dropping the oldest past the call or size cap. */
64export function remember(store: Map<string, string>, id: string, full: string): void {
65 store.delete(id)
66 store.set(id, full)
67 let chars = 0
68 for (const text of store.values()) chars += text.length
69 while (store.size > KEEP_CALLS || chars > KEEP_CHARS) {
70 const oldest = store.keys().next().value
71 if (oldest === undefined) break
72 chars -= store.get(oldest)?.length ?? 0
73 store.delete(oldest)
74 }
75}
76
77function debug($: EngineInterface, error: unknown): void {
78 $.ui.log(`trim: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
79}
80
81function redraw($: EngineInterface): void {
82 try {
83 $.ui.invalidate('ui.render')
84 } catch {
85 // nothing drawing
86 }
87}
88
89/**
90 * A result trim may fold: a Bash record of plain text output, not an image, a background job or one
91 * core already saved to a file. A failed call's result is its error text, never a record: it passes.
92 */
93function foldable(result: unknown): BashRecord | null {
94 const out = result as Partial<BashRecord> | null | undefined
95 if (out === null || typeof out !== 'object' || typeof out.stdout !== 'string') return null
96 if (out.isImage || out.backgroundTaskId || out.persistedOutputPath || out.structuredContent) return null
97 return out as BashRecord
98}
99
100async function openPane($: EngineInterface, id: string, title: string): Promise<boolean> {
101 try {
102 return (await $.ui.open({ id, title, focus: true, closeOnEscape: true })).isPlaced
103 } catch {
104 return false
105 }
106}
107
108function summary(l: TrimLog): string {
109 if (l.count === 0) return 'trim: nothing folded yet this session.'
110 return `trim: ${grouped(l.count)} call${l.count === 1 ? '' : 's'} folded, ${aboutTokens(l.saved)} tokens saved this session.`
111}
112
113/** /trim and its words; every answer is a toast or a pane, never text Claude reads. */
114async function command($: EngineInterface, args: string, origin: string): Promise<void> {
115 const [verb = '', arg = ''] = args.trim().split(/\s+/)
116 const action = verb.toLowerCase()
117 if ((action === 'off' || action === 'on') && !PERSON.has(origin)) {
118 return $.ui.toast(`trim: /trim ${action} runs only when you type it yourself.`)
119 }
120 if (action === 'off' || action === 'on') {
121 await update($, isOff, () => action === 'off')
122 return $.ui.toast(action === 'off' ? 'trim is paused for this session. /trim on resumes it.' : 'trim is on again.')
123 }
124 const l = await read($, log)
125 if (action === '' || action === 'list') {
126 if (!(await openPane($, LIST_PANE, 'trim'))) $.ui.toast(summary(l))
127 return
128 }
129 if (action === 'show') {
130 const n = Number(arg)
131 const call = l.calls.find(c => c.n === n)
132 if (!call || !fulls.has(call.id)) {
133 return $.ui.toast(`trim: no full output for call ${oneLine(clean(arg), 12) || '(none given)'}. /trim lists the last ${KEEP_CALLS}.`)
134 }
135 await update($, shown, () => n)
136 if (!(await openPane($, OUTPUT_PANE, `trim: call ${n}`))) $.ui.toast('trim: the full output draws in a pane, which only the terminal and the desktop app have.')
137 redraw($)
138 return
139 }
140 $.ui.toast(`trim: unknown command "${oneLine(clean(verb), 24)}". ${USAGE}`)
141}
142
143export const register: Register = (on, options) => {
144 settings = readSettings(options)
145 isOn = options.enabled !== false
146
147 on('config.set', { key: 'trim.enabled' }, async ($, e, next) => {
148 const set = await next(e)
149 if (set.deny === undefined && typeof set.value === 'boolean') {
150 isOn = set.value
151 // The status line is trim's too: gone while off, the session's total again when on.
152 try {
153 const l = await read($, log)
154 $.ui.status(isOn && l.saved > 0 ? statusLine(l.saved) : undefined)
155 } catch (error) {
156 debug($, error)
157 }
158 redraw($)
159 }
160 return set
161 })
162
163 on('session.start', async ($, e, next) => {
164 try {
165 await $.command.register({
166 name: 'trim',
167 description: 'List the shell outputs trim folded, show one in full, or pause it',
168 argumentHint: '[show <n> | off | on]',
169 immediate: true,
170 })
171 const l = await read($, log)
172 if (isOn && l.saved > 0) $.ui.status(statusLine(l.saved))
173 } catch (error) {
174 debug($, error)
175 }
176 return next(e)
177 })
178
179 // The fold itself. The tool has run once its answer is in hand, so nothing here may run it
180 // again: any failure answers with what the tool gave, untouched.
181 on('tool.call', async ($, e, next) => {
182 const ran = await next(e)
183 if (!isOn || e.tool !== 'Bash' || ran.deny !== undefined) return ran
184 try {
185 if (await read($, isOff)) return ran
186 const out = foldable(ran.result)
187 if (out === null) return ran
188 const trimmed = trimOutput(out.stdout, settings)
189 if (trimmed === null) return ran
190 const id = e.tool_use_id ?? ''
191 if (id !== '') remember(fulls, id, out.stdout)
192 const l = await update($, log, prev => {
193 const call: TrimCall = {
194 n: prev.count + 1,
195 id,
196 command: oneLine(clean(e.command), 80),
197 linesIn: trimmed.linesIn,
198 folded: trimmed.folded,
199 saved: trimmed.saved,
200 }
201 return { calls: [...prev.calls, call].slice(-KEEP_CALLS), count: prev.count + 1, saved: prev.saved + trimmed.saved }
202 })
203 $.ui.status(statusLine(l.saved))
204 return { result: { ...out, stdout: trimmed.text }, ...(ran.context ? { context: ran.context } : {}) }
205 } catch (error) {
206 debug($, error)
207 return ran
208 }
209 })
210
211 // Under a folded call's row: what was saved, and expand (the drawing only; Claude's read stays folded).
212 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
213 if (!isOn || e.props.tool !== 'Bash' || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
214 const id = e.props.tool_use_id
215 const call = (await read($, log)).calls.find(c => c.id === id)
216 if (call === undefined) return next(e)
217 const full = fulls.get(id)
218 const isOpen = full !== undefined && expanded.has(id)
219 const output = e.props.output as BashRecord | undefined
220 const row = await next(isOpen && output ? { ...e, props: { ...e.props, output: { ...output, stdout: full } } } : e)
221 const { Box, Button, Text } = $.ui.resolve(e)
222 return (
223 <Box flexDirection="column">
224 {row}
225 <Box key="trim" flexDirection="row" gap={1}>
226 <Text key="saved" dimColor>
227 {rowLine(call)}
228 </Text>
229 {full === undefined ? null : (
230 <Button
231 key="expand"
232 label={isOpen ? 'fold' : 'expand'}
233 onPress={() => {
234 if (expanded.has(id)) expanded.delete(id)
235 else expanded.add(id)
236 redraw($)
237 }}
238 />
239 )}
240 </Box>
241 </Box>
242 )
243 }).catch(($, e, next) => next(e))
244
245 // /trim: one row per folded call, with Show for each one still kept in full.
246 on('ui.render', { component: 'Pane', requestId: LIST_PANE }, async ($, e) => {
247 const { Box, Button, Text } = $.ui.resolve(e)
248 if (!isOn) {
249 return (
250 <Box flexDirection="column" paddingX={1}>
251 <Text key="off" dimColor>
252 {OFF_TEXT}
253 </Text>
254 </Box>
255 )
256 }
257 const l = await read($, log)
258 const paused = await read($, isOff)
259 return (
260 <Box flexDirection="column" paddingX={1}>
261 <Text key="total" bold>
262 {l.count === 0 ? 'Nothing folded yet this session' : `${grouped(l.count)} call${l.count === 1 ? '' : 's'} folded · ${aboutTokens(l.saved)} tokens saved`}
263 </Text>
264 {paused ? (
265 <Text key="paused" color="warning">
266 Paused for this session. /trim on resumes it.
267 </Text>
268 ) : null}
269 {[...l.calls].reverse().map(call => (
270 <Box key={`call-${call.n}`} flexDirection="row" gap={1}>
271 <Box key="n" width={4} flexShrink={0}>
272 <Text dimColor>{`#${call.n}`}</Text>
273 </Box>
274 <Box key="command" flexGrow={1} flexShrink={1} minWidth={8}>
275 <Text wrap="truncate-end">{call.command}</Text>
276 </Box>
277 <Box key="lines" flexShrink={0}>
278 <Text dimColor>{`${grouped(call.linesIn)} lines, ${grouped(call.folded)} folded · ${shortTokens(call.saved)}`}</Text>
279 </Box>
280 {fulls.has(call.id) ? (
281 <Button
282 key="show"
283 label="Show"
284 onPress={async () => {
285 await update($, shown, () => call.n)
286 await openPane($, OUTPUT_PANE, `trim: call ${call.n}`)
287 redraw($)
288 }}
289 />
290 ) : null}
291 </Box>
292 ))}
293 <Text key="note" dimColor>
294 Token figures are estimates (about four characters each). Full outputs are kept in memory for the last {KEEP_CALLS} calls only.
295 </Text>
296 </Box>
297 )
298 })
299
300 // /trim show <n>: one call's full output, on the person's screen only.
301 on('ui.render', { component: 'Pane', requestId: OUTPUT_PANE }, async ($, e) => {
302 const { Box, Text } = $.ui.resolve(e)
303 if (!isOn) {
304 return (
305 <Box flexDirection="column" paddingX={1}>
306 <Text key="off" dimColor>
307 {OFF_TEXT}
308 </Text>
309 </Box>
310 )
311 }
312 const n = await read($, shown)
313 const call = (await read($, log)).calls.find(c => c.n === n)
314 const full = call ? fulls.get(call.id) : undefined
315 return (
316 <Box flexDirection="column" paddingX={1}>
317 <Text key="command" bold>
318 {call ? `#${call.n} ${call.command}` : 'No call to show'}
319 </Text>
320 <Text key="output">{full === undefined ? 'This output is no longer kept: only the last 20 calls are.' : clean(full.slice(0, SHOW_CHARS))}</Text>
321 {full !== undefined && full.length > SHOW_CHARS ? (
322 <Text key="more" dimColor>
323 {`Showing the first ${grouped(SHOW_CHARS)} of ${grouped(full.length)} characters.`}
324 </Text>
325 ) : null}
326 </Box>
327 )
328 })
329
330 on('command.run', { command: 'trim' }, async ($, e) => {
331 if (!isOn) {
332 $.ui.toast(OFF_TEXT)
333 return {}
334 }
335 try {
336 await command($, e.args, e.origin.kind)
337 } catch (error) {
338 debug($, error)
339 $.ui.toast('trim: that command failed, and nothing changed.')
340 }
341 return {}
342 })
343}
344hooks/logic.ts 236 lines1// The pure part of trim: when a shell output is long enough to fold, what stays,
2// how repeats fold, the footer Claude reads, and the words the person sees.
3//
4// What stays: the first lines, the last lines, every line that reads like an
5// error or a warning, and every line the person's own pattern names. A run of
6// lines that differ only in their numbers (a progress bar, a counter) folds to
7// one line with a count, and a warning repeated through the output is kept once
8// with how many times it came. stderr is never touched: it is not in here.
9
10/** Lines always kept from the top and the bottom. */
11export const HEAD = 15
12export const TAIL = 40
13/** A line kept longer than this is cut in its middle (a minified file, a huge JSON line). */
14export const MAX_LINE = 2_000
15/** Error-like lines kept from the middle, at most; the rest fold with a count. */
16export const MAX_MIDDLE = 150
17/** A run of this many like lines or more folds to one. */
18export const RUN = 4
19/** Folding has to save at least this share of the output to be worth a footer. */
20export const MIN_SAVING = 0.25
21/** Characters per token, for an estimate that always says "about". */
22export const CHARS_PER_TOKEN = 4
23
24export type Settings = {
25 /** Fold only past this many lines, or `chars` characters. */
26 lines: number
27 chars: number
28 /** Lines matching this are always kept; undefined when unset or not a valid pattern. */
29 keep: RegExp | undefined
30}
31
32export const DEFAULTS: Settings = { lines: 120, chars: 6_000, keep: undefined }
33
34/**
35 * What marks a line worth keeping wherever it is: error, failure and warning words, and the marks
36 * test runners print on a failed test (Jest's ✕ and ● headers, Vitest's ×, others' ✗ and ✘), which a
37 * passing exit code (a pipe into tee, `|| true`) would otherwise fold away.
38 */
39export const ERROR_LINE =
40 /\b(?:err(?:or)?s?|fail(?:s|ed|ure|ures|ing)?|exception|traceback|warn(?:ing|ings)?|panic(?:ked)?|fatal|denied|refused|not found|cannot|can't|unable to|abort(?:ed)?|segmentation fault)\b|ERR!|[✗✘✕×●]/i
41
42export type Trimmed = {
43 /** What Claude reads in place of stdout, footer included. */
44 text: string
45 linesIn: number
46 /** Lines not shown as themselves: dropped, or folded into a like line's count. */
47 folded: number
48 charsIn: number
49 /** Characters saved, footer counted against it. */
50 saved: number
51}
52
53/** The person's settings, each clamped to a sane range; a pattern that does not compile is left out. */
54export function readSettings(options: Record<string, unknown>): Settings {
55 const num = (v: unknown, fallback: number, lo: number, hi: number) =>
56 typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : fallback
57 let keep: RegExp | undefined
58 const raw = options.keep
59 if (typeof raw === 'string' && raw.trim() !== '' && raw.length <= 500) {
60 try {
61 keep = new RegExp(raw, 'i')
62 } catch {
63 keep = undefined
64 }
65 }
66 return { lines: num(options.lines, DEFAULTS.lines, 40, 100_000), chars: num(options.chars, DEFAULTS.chars, 1_000, 10_000_000), keep }
67}
68
69/**
70 * A line with its percentages and counters made alike, so `42%` and `43%` (or `12/300` and
71 * `13/300`) read as the same line. Other numbers stay: `line 1` and `line 2` are two lines.
72 */
73export function likeness(line: string): string {
74 return line
75 .replace(/\d+(?:[.,]\d+)?\s*%/g, '#%')
76 .replace(/\d+\s*\/\s*\d+/g, '#/#')
77 .replace(/\s+/g, ' ')
78 .trim()
79}
80
81/** A kept line too long to read whole: its head and tail, with how much was cut. */
82export function clip(line: string): string {
83 if (line.length <= MAX_LINE) return line
84 const head = 1_400
85 const tail = 500
86 return `${line.slice(0, head)} [... ${grouped(line.length - head - tail)} characters folded ...] ${line.slice(-tail)}`
87}
88
89/** 12345 as "12,345". */
90export function grouped(n: number): string {
91 return Math.round(n).toLocaleString('en-US')
92}
93
94/** A rough token count from characters, rounded so it never reads as exact: "about 11,000". */
95export function aboutTokens(chars: number): string {
96 const tokens = chars / CHARS_PER_TOKEN
97 if (tokens < 1_000) return `about ${grouped(Math.max(10, Math.round(tokens / 10) * 10))}`
98 return `about ${grouped(Math.round(tokens / 100) * 100)}`
99}
100
101/** The short form for tight places: "~11k", "~800". */
102export function shortTokens(chars: number): string {
103 const tokens = chars / CHARS_PER_TOKEN
104 if (tokens >= 1_000) return `~${tokens >= 10_000 ? Math.round(tokens / 1_000) : Math.round(tokens / 100) / 10}k`
105 return `~${Math.max(10, Math.round(tokens / 10) * 10)}`
106}
107
108/**
109 * The line Claude reads last, so it never takes the folded output for the whole of it. A re-run is
110 * offered only for a command that is safe to repeat: an install, a migration or a deploy must not
111 * run twice to read its log, and the person can see the whole output under the call.
112 */
113export function footer(folded: number, linesIn: number, saved: number): string {
114 return (
115 `[trim: ${grouped(folded)} of ${grouped(linesIn)} lines folded, ${aboutTokens(saved)} tokens saved. ` +
116 'The folded lines were not sent. If the command only reads (a test run, a build, a listing), run it again ' +
117 'through grep, sed -n or tail to read a part; never repeat one that changes anything (an install, a migration, ' +
118 'a deploy): ask the person, who can see the whole output.]'
119 )
120}
121
122/**
123 * Folds `stdout` when it is past the line or character limit, else null (left
124 * as it is). Also null when folding would save too little to be worth it.
125 */
126export function trimOutput(stdout: string, settings: Settings): Trimmed | null {
127 const endsWithNewline = stdout.endsWith('\n')
128 const lines = (endsWithNewline ? stdout.slice(0, -1) : stdout).split('\n')
129 const n = lines.length
130 if (n <= settings.lines && stdout.length <= settings.chars) return null
131
132 const isKept = (line: string, i: number) =>
133 i < HEAD || i >= n - TAIL || ERROR_LINE.test(line.slice(0, MAX_LINE)) || (settings.keep?.test(line.slice(0, MAX_LINE)) ?? false)
134
135 // The middle's error-like lines, each kept once with how often it came word for word (never by
136 // likeness: two errors at different line numbers are two errors); past MAX_MIDDLE they fold.
137 const seen = new Map<string, number>()
138 for (let i = HEAD; i < n - TAIL; i++) {
139 const line = lines[i]!
140 if (isKept(line, i)) seen.set(line, (seen.get(line) ?? 0) + 1)
141 }
142
143 const out: string[] = []
144 let run = 0 // folded lines since the last kept one
145 let middleKept = 0
146 const emitted = new Set<string>()
147 const flush = () => {
148 if (run > 0) out.push(`[... ${grouped(run)} line${run === 1 ? '' : 's'} folded ...]`)
149 run = 0
150 }
151 for (let i = 0; i < n; i++) {
152 const line = lines[i]!
153 const isMiddle = i >= HEAD && i < n - TAIL
154 if (!isKept(line, i)) {
155 run++
156 continue
157 }
158 if (isMiddle) {
159 if (emitted.has(line) || middleKept >= MAX_MIDDLE) {
160 run++
161 continue
162 }
163 emitted.add(line)
164 middleKept++
165 flush()
166 const times = seen.get(line) ?? 1
167 out.push(times > 1 ? `${clip(line)} [${grouped(times)} times]` : clip(line))
168 continue
169 }
170 flush()
171 out.push(clip(line))
172 }
173 flush()
174
175 const body = foldRuns(out)
176 const draft = body.join('\n')
177 // Every original line not shown as itself is folded: a marker stands for many, a run's line for one.
178 const shown = body.filter(line => !isMarker(line)).length
179 const charsIn = stdout.length
180 const withFooter = (saved: number) => `${draft}\n${footer(n - shown, n, saved)}${endsWithNewline ? '\n' : ''}`
181 // The footer counts against the saving: word it with the figure before it, then measure what stands.
182 const text = withFooter(charsIn - withFooter(charsIn - draft.length).length)
183 const saved = charsIn - text.length
184 if (saved < Math.max(1_000, charsIn * MIN_SAVING)) return null
185 return { text, linesIn: n, folded: n - shown, charsIn, saved }
186}
187
188/** The line that stands for a run of folded lines. */
189export function isMarker(line: string): boolean {
190 return /^\[\.\.\. [\d,]+ lines? folded \.\.\.\]$/.test(line)
191}
192
193/**
194 * A run of RUN or more like lines (a progress bar, a counter) becomes its first line with a count.
195 * Error-like lines never fold this way: each keeps its own file and line number.
196 */
197export function foldRuns(lines: readonly string[]): string[] {
198 const out: string[] = []
199 const isFoldable = (line: string) => !isMarker(line) && !ERROR_LINE.test(line.slice(0, MAX_LINE))
200 for (let i = 0; i < lines.length; ) {
201 const like = likeness(lines[i]!)
202 let j = i + 1
203 while (j < lines.length && likeness(lines[j]!) === like && isFoldable(lines[j]!)) j++
204 const count = j - i
205 if (count >= RUN && isFoldable(lines[i]!)) out.push(`${lines[i]} [and ${grouped(count - 1)} more like it]`)
206 else out.push(...lines.slice(i, j))
207 i = j
208 }
209 return out
210}
211
212/** One line for a drawing: whitespace runs as one space, at most `max` characters. */
213export function oneLine(text: string, max: number): string {
214 const flat = text.replace(/\s+/g, ' ').trim()
215 return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
216}
217
218/** Control characters and escape sequences out of anything drawn: shell output is full of them. */
219export function clean(text: string): string {
220 return text
221 .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
222 .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
223 .replace(/\t/g, ' ')
224 .replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '�')
225}
226
227/** The savings line under a trimmed row. */
228export function rowLine(call: { folded: number; saved: number }): string {
229 return `trim: ${grouped(call.folded)} lines folded · ${shortTokens(call.saved)} tokens saved`
230}
231
232/** The status line once anything was saved this session. */
233export function statusLine(saved: number): string {
234 return `trim ${shortTokens(saved)} saved`
235}
236types/index.d.ts 41 lines1// What trim keeps in $.state for the session: the calls it folded (the last 20),
2// the session's totals, whether it is paused, and which call's full output the
3// output pane shows. The full outputs themselves live only in the module's own
4// memory, never in $.state, $.store or a file.
5
6/** One Bash call trim folded. */
7export type TrimCall = {
8 /** Its place in this session, from 1. */
9 n: number
10 /** The tool call's id, which its transcript row carries. */
11 id: string
12 /** The command's first line, cleaned, at most 80 characters. */
13 command: string
14 linesIn: number
15 /** Lines not shown as themselves to Claude. */
16 folded: number
17 /** Characters saved, an estimate's basis (tokens are about a quarter of it). */
18 saved: number
19}
20
21export type TrimLog = {
22 /** The last 20 folded calls, oldest first. */
23 calls: TrimCall[]
24 /** Calls folded this session, all of them. */
25 count: number
26 /** Characters saved this session, all calls. */
27 saved: number
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 trim: {
33 log: TrimLog
34 /** The person paused it for the session (/trim off). */
35 isOff: boolean
36 /** The call (by n) whose full output the output pane shows; 0 for none. */
37 shown: number
38 }
39 }
40}
41