SLOPSHOPPER

trim

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

newpanerowsguardcommandtoast
★ 1v0.1.0MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/trim
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trim
│ ┃ trim ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing folded yet this session │ ┃ Token figures are estimates (about four ⏺ Read(src/auth.ts) │ ┃ characters each). Full outputs are kept in ⎿ Read 6 lines │ ┃ memory for the last 20 calls only. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /trim │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · trim
Nothing folded yet this session Token figures are estimates (about four characters each). Full outputs are kept in memory for the last 20 calls only.
Pane · trim-output
No call to show This output is no longer kept: only the last 20 calls are.
README

<img src="../../brand/pixel-icons/svg/trim.svg" width="64" height="64" alt="">

Trim

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]

Install

/plugin install trim --marketplace modmyclaude/claude-code-mods

Use it

Nothing to set up. When a Bash command prints more than 120 lines or 6,000 characters, Claude reads a folded copy:

  • What stays: the first 15 lines, the last 40, every line that reads like an error or a warning (error, failed, exception, traceback, warning, panic, fatal, not found, cannot and the like, and the marks test runners print on a failed test: ✕, ×, ●, ✗, ✘), and every line your own pattern names. A failed test stays even when the command itself exits cleanly (piped into tee, or after || true).
  • What folds: everything else, as one [... 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.
  • The footer tells Claude what was folded and about how many tokens it saved. If Claude needs a folded part of a command that only reads (a test run, a build, a listing), it can run it again through 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.
  • Never touched: short output, stderr, a command that failed (its output reaches Claude whole: the errors are what Claude needs), images, background jobs, output Claude Code already saved to a file, and every tool but Bash.
  • Folding that saves little (under a quarter of the output, or under 1,000 characters) is skipped.

You still see everything:

  • Under each folded call a dim line says what was saved, with expand, which draws the full output in that row. It changes the drawing only: Claude's copy stays folded.
  • The status line keeps the session's total: trim ~34k saved.
CommandWhat it does
/trimA 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 offPause it for this session (only when you type it)
/trim onResume 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 ~.

Settings

Every setting has a default, so the notice at install about options not yet set is expected; change them in /config.

  • On: on by default. Off, Trim folds nothing and keeps nothing: every Bash output reaches Claude whole, as Claude Code made it, and no line under a call, status line or pane is drawn; /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.)
  • Fold past this many lines (default 120, from 40 to 100,000) and Fold past this many characters (default 6,000): output past either is folded.
  • Always keep lines matching: a pattern (a regular expression, case-insensitive; | between several, such as deploy id|migrated) whose lines never fold. A pattern that does not compile is ignored.

What it can touch

  • Reads: the result of every Bash call (stdout and its record), only to fold it. Nothing else of the conversation.
  • Rewrites: what Claude reads of a long Bash output, in memory, before Claude reads it. The transcript stores the folded copy, as Claude saw it.
  • Keeps: the full output of the last 20 folded calls (two million characters at most), in the mod's own memory only, to show you; gone when the session ends or the mod reloads. Session state holds each folded call's command (its first line, 80 characters), line counts and savings, the session's totals, and whether it is paused.
  • Shows: the line under a folded call, the status line, toasts and the /trim panes. The panes and the expand never reach Claude; /trim answers with no text for the model.
  • Files, processes, network, model calls: none.

Limits

  • A failed command is never folded, however long: Claude Code reports it as an error, and its errors are what Claude needs.
  • The error words are English and pattern-based. An error worded otherwise can fold; name it in the keep setting.
  • Lines past 2,000 characters are cut in their middle (a minified file, a long JSON line); the cut says how many characters went.
  • In the middle of an output, at most 150 different error-like lines are kept; past that they fold.
  • Claude can still run the command again to read a folded part, which costs a call; the footer suggests a filter so that read stays small.
  • After a reload, full outputs are gone: a folded row keeps its savings line, without expand. A call older than the last 20 shows only the folded copy, as Claude read it.
  • The row, the panes and the status line are checked through the engine's test kit on the terminal and desktop surfaces. That Claude reads the folded copy was checked live in a headless session.

Built from a prompt

This mod was built from the Trim prompt.

More mods: the full catalogue.

Source 3 files
hooks/register.tsx 344 lines
1// 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}
344
hooks/logic.ts 236 lines
1// 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}
236
types/index.d.ts 41 lines
1// 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