SLOPSHOPPER

tldr

Shows a TLDR, You Should Know and What To Do Next box above the prompt after long responses

newbandrowspromptmodeltimer
v0.4.0no licenseupdated 2026-10-13alzerapradisa/claude-mod-tldr
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tldr
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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 >> TLDR; skipped: reply 176 chars, under 800 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
>> TLDR; skipped: reply 176 chars, under 800
README

tldr

Claude Code mod. Long reply? Get a short summary box above the prompt.

tldr box above the Claude Code prompt

Features

FeatureWhat it does
TLDR;2-3 sentence conclusion of the reply
You Should KnowKey facts, warnings, decisions, paths, commands
What To Do NextConcrete next steps for you
Follows scrollScroll up: box summarizes the message cut at the bottom of the view
CollapsePress t to shrink box to one line, t again to expand
CacheEach summary made once per session, then reused
Resumeclaude --resume shows summary of last reply
Skips noiseIgnores short replies, subagent turns, interrupted turns
Status lineShows why a reply was skipped or why summarizing failed

Summaries made by haiku: fast and cheap.

Tutorial

1. Get the mod

git clone https://github.com/alzerapradisa/claude-mod-tldr.git ~/.claude/mods/tldr

2. Tell Claude Code to load it

Edit ~/.claude/settings.json. Add the mod folder to CLAUDE_CODE_PLUGIN_DIRS:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/tldr"
  }
}

Already have other mods? Join paths with ::

"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/tldr:~/.claude/mods/other-mod"

3. Restart Claude Code

Quit and start claude again.

4. Try it

Ask something with a long answer:

Explain how this project is structured

When the reply finishes:

  1. Status line shows summarizing latest response (haiku)...
  2. Box appears above the prompt
  3. Press t to collapse or expand it

5. Read older replies

Scroll up. When a long message is cut at the bottom of the view, box switches to that message (message at bottom of view). Scroll back down to return to the latest reply.

6. Update

git -C ~/.claude/mods/tldr pull

Restart Claude Code.

Config

Set options in ~/.claude/settings.json under pluginConfigs:

{
  "pluginConfigs": {
    "tldr@inline": {
      "options": {
        "minChars": 600,
        "showKnow": true,
        "showNext": false
      }
    }
  }
}
OptionTypeDefaultWhat
minCharsnumber800Only summarize replies at least this long
showKnowbooleantrueShow "You Should Know" section
showNextbooleantrueShow "What To Do Next" section

showNext: false - TLDR; line then covers what to do too.

Restart Claude Code after changing config.

Troubleshooting

Status line saysMeaning
skipped: reply N chars, under MReply shorter than minChars
skipped: turn ended by ...Turn interrupted or errored, nothing summarized
failed: api-error ...Haiku call failed, check network / API access
failed: summary not in expected...Haiku output in bad format, try next reply

Files

PathWhat
hooks/register.tsxMod logic and UI
hooks/register.test.tsxTests
types/index.d.tsState types
.claude-plugin/plugin.jsonManifest and config schema
Source 2 files
hooks/register.tsx 304 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Status, Summary } from '../types'
5
6const MODEL = 'haiku'
7const ACCENT = 'warning'
8const SCROLL_SETTLE_MS = 400
9
10type Sections = { know: boolean; next: boolean }
11
12const KNOW_HEADER = 'You Should Know'
13const NEXT_HEADER = 'What To Do Next'
14
15const systemFor = (sections: Sections) => {
16  const blocks = [`TLDR;\n<2-3 short sentences: ${sections.next ? 'the conclusion' : 'the conclusion and what to do'}>`]
17  if (sections.know) {
18    blocks.push(`${KNOW_HEADER}\n- <important fact, warning, decision, file path, command, or caveat>\n- <...>`)
19  }
20  if (sections.next) {
21    blocks.push(`${NEXT_HEADER}\n- <concrete next step for the reader: command to run, file to check, decision to make>\n- <...>`)
22  }
23  const bulletRule = sections.know || sections.next ? '1-6 bullets per section, each one line. If nothing to do next, write "- Nothing." ' : ''
24  return `You summarize an AI assistant's response for a reader who finds long responses hard to read.
25Output exactly this format, plain text, nothing before or after:
26
27${blocks.join('\n\n')}
28
29Rules: ${bulletRule}Keep exact names, paths, commands, numbers. No emoji. No markdown. No filler. Do not invent anything not in the response.`
30}
31
32let sections: Sections = { know: true, next: true }
33
34const view = atom({ plugin: 'tldr', key: 'view' } as const, null)
35const isCollapsed = atom({ plugin: 'tldr', key: 'isCollapsed' } as const, false)
36const status = atom({ plugin: 'tldr', key: 'status' } as const, null)
37
38const setStatus = ($: EngineInterface, next: Status | null) => update($, status, () => next)
39
40export const parse = (text: string, want: Sections): Summary => {
41  const summary: Summary = { tldr: [], know: [], next: [] }
42  const seen = new Set<keyof Summary>(['tldr'])
43  let section: keyof Summary = 'tldr'
44  for (const line of text.split('\n').map(l => l.trim()).filter(Boolean)) {
45    if (/^tldr;?$/i.test(line)) continue
46    if (/^you should know/i.test(line)) {
47      section = 'know'
48    } else if (/^what to do next/i.test(line)) {
49      section = 'next'
50    } else {
51      summary[section].push(section === 'tldr' ? line : line.replace(/^[-*]\s*/, ''))
52      continue
53    }
54    seen.add(section)
55  }
56  if (want.know && !seen.has('know')) {
57    throw new Error(`tldr: summary missing "${KNOW_HEADER}" section`)
58  }
59  if (want.next && !seen.has('next')) {
60    throw new Error(`tldr: summary missing "${NEXT_HEADER}" section`)
61  }
62  return summary
63}
64
65const hashOf = async (text: string) => {
66  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
67  return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('')
68}
69
70const storeKey = async ($: EngineInterface) => `summaries:${await $.session.id()}`
71
72const loadAll = async ($: EngineInterface) =>
73  ((await $.store.get(await storeKey($))) ?? {}) as Record<string, Summary>
74
75const pending = new Set<string>()
76let latestText: string | null = null
77let focusId: string | null = null
78let settle: Timer | null = null
79
80const summaryFor = async ($: EngineInterface, text: string, what: string): Promise<Summary | null> => {
81  const hash = await hashOf(`${sections.know ? 'k' : ''}${sections.next ? 'n' : ''}:${text}`)
82  const hit = (await loadAll($))[hash]
83  if (hit) return hit
84  if (pending.has(hash)) return null
85
86  pending.add(hash)
87  await setStatus($, { text: `summarizing ${what} (${MODEL})...`, isError: false })
88  try {
89    const reply = await $.model.complete({
90      model: MODEL,
91      system: systemFor(sections),
92      prompt: `<response>\n${text}\n</response>`,
93      maxTokens: 600,
94    })
95    if (!reply.isAnswered) {
96      const detail = reply.reason === 'api-error' ? `api-error ${reply.status} ${reply.error}` : reply.reason
97      await setStatus($, { text: `failed: ${detail}`, isError: true })
98      return null
99    }
100    let parsed: Summary
101    try {
102      parsed = parse(reply.text, sections)
103    } catch (err) {
104      await setStatus($, { text: 'failed: summary not in expected section format', isError: true })
105      throw err
106    }
107    const all = await loadAll($)
108    await $.store.set(await storeKey($), { ...all, [hash]: parsed })
109    await setStatus($, null)
110    return parsed
111  } finally {
112    pending.delete(hash)
113  }
114}
115
116const showLatest = async ($: EngineInterface) => {
117  if (latestText === null) {
118    await update($, view, () => null)
119    return
120  }
121  const summary = await summaryFor($, latestText, 'latest response')
122  if (summary && focusId === null) {
123    await update($, view, () => ({ summary, isLatest: true }))
124  }
125}
126
127const showFocused = async ($: EngineInterface, id: string, text: string) => {
128  const summary = await summaryFor($, text, 'message at bottom of view')
129  if (summary && focusId === id) {
130    await update($, view, () => ({ summary, isLatest: false }))
131  }
132}
133
134export const register: Register = (on, options) => {
135  const MIN_CHARS = options.minChars
136  if (typeof MIN_CHARS !== 'number' || !Number.isInteger(MIN_CHARS) || MIN_CHARS < 1) {
137    throw new Error(`tldr: minChars must be a positive integer, got ${String(MIN_CHARS)}`)
138  }
139  const { showKnow, showNext } = options
140  if (typeof showKnow !== 'boolean' || typeof showNext !== 'boolean') {
141    throw new Error(`tldr: showKnow and showNext must be booleans, got ${String(showKnow)}, ${String(showNext)}`)
142  }
143  sections = { know: showKnow, next: showNext }
144
145  on('prompt.submit', async ($, e, next) => {
146    latestText = null
147    focusId = null
148    await update($, view, () => null)
149    await setStatus($, { text: 'waiting for response to finish', isError: false })
150    return next(e)
151  })
152
153  on('turn.complete', async ($, e, next) => {
154    const result = await next(e)
155    if (e.agentId) {
156      return result
157    }
158    if (e.reason !== 'answer') {
159      await setStatus($, { text: `skipped: turn ended by ${e.reason}`, isError: false })
160      return result
161    }
162    if (e.answer.length < MIN_CHARS) {
163      await setStatus($, { text: `skipped: reply ${e.answer.length} chars, under ${MIN_CHARS}`, isError: false })
164      return result
165    }
166    latestText = e.answer
167    focusId = null
168    await showLatest($)
169    return result
170  })
171
172  on('classic.SessionStart', { source: 'resume' }, async ($, e, next) => {
173    const result = await next(e)
174    focusId = null
175    const last = (await $.session.messages()).findLast(m => m.role === 'assistant' && m.text)
176    latestText = last && last.text.length >= MIN_CHARS ? last.text : null
177    if (latestText === null) {
178      await setStatus($, { text: `skipped: last reply of resumed session under ${MIN_CHARS} chars`, isError: false })
179    }
180    await showLatest($)
181    return result
182  })
183
184  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
185    const pos = e.props.onScreen
186    if (pos === null && focusId === e.requestId) {
187      focusId = null
188      settle?.cancel()
189      void showLatest($)
190    }
191    const isCutAtBottom = pos && pos.last < pos.of - 1
192    if (!isCutAtBottom || focusId === e.requestId || e.props.text.length < MIN_CHARS) {
193      return next(e)
194    }
195
196    const id = e.requestId
197    const text = e.props.text
198    focusId = id
199    settle?.cancel()
200    settle = $.clock.after(SCROLL_SETTLE_MS, () => void showFocused($, id, text))
201    return next(e)
202  })
203
204  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
205    const v = await read($, view)
206    const s = await read($, status)
207    if ((v === null && s === null) || e.props.hasSurvey) {
208      return next(e)
209    }
210
211    const { Box, Button, Text } = $.ui.resolve(e)
212    const statusLine = s && (
213      <Text color={s.isError ? 'error' : undefined} dimColor={!s.isError} wrap="truncate-end">
214        {`  ${s.text}`}
215      </Text>
216    )
217
218    if (v === null) {
219      return (
220        <Box width={e.props.bodyColumns}>
221          <Box flexShrink={0}>
222            <Text color={ACCENT} bold>
223              {'>> TLDR;'}
224            </Text>
225          </Box>
226          <Box flexShrink={1} minWidth={0}>
227            {statusLine}
228          </Box>
229        </Box>
230      )
231    }
232
233    const { summary } = v
234    const collapsed = await read($, isCollapsed)
235    const toggle = (
236      <Box flexShrink={0}>
237        <Button
238          key="toggle"
239          label={collapsed ? '+' : '-'}
240          hotkey="t"
241          onPress={() => update($, isCollapsed, c => !c)}
242        />
243      </Box>
244    )
245    const label = (
246      <Box flexShrink={0}>
247        <Text backgroundColor={ACCENT} color="inverseText" bold wrap="truncate-end">
248          {' >> TLDR; '}
249        </Text>
250      </Box>
251    )
252
253    const bullets = (prefix: string, header: string, items: string[]) => (
254      <Box key={prefix} flexDirection="column">
255        <Text> </Text>
256        <Text color={ACCENT} bold>
257          {header}
258        </Text>
259        {items.map((item, i) => (
260          <Text key={`${prefix}${i}`}>
261            <Text color={ACCENT}>{' > '}</Text>
262            {item}
263          </Text>
264        ))}
265      </Box>
266    )
267
268    if (collapsed) {
269      return (
270        <Box borderStyle="double" borderColor={ACCENT} paddingX={1} width={e.props.bodyColumns}>
271          {label}
272          <Box flexGrow={1} flexShrink={1} minWidth={0} paddingX={1}>
273            <Text bold wrap="truncate-end">
274              {summary.tldr.join(' ')}
275            </Text>
276          </Box>
277          {toggle}
278        </Box>
279      )
280    }
281
282    return (
283      <Box
284        flexDirection="column"
285        borderStyle="double"
286        borderColor={ACCENT}
287        paddingX={1}
288        width={e.props.bodyColumns}
289      >
290        <Box>
291          {label}
292          <Box flexGrow={1}>
293            {statusLine ?? <Text dimColor>{v.isLatest ? '  latest response' : '  message at bottom of view'}</Text>}
294          </Box>
295          {toggle}
296        </Box>
297        <Text bold>{summary.tldr.join(' ')}</Text>
298        {sections.know && bullets('k', `!! ${KNOW_HEADER}`, summary.know)}
299        {sections.next && bullets('n', `>> ${NEXT_HEADER}`, summary.next)}
300      </Box>
301    )
302  })
303}
304
types/index.d.ts 10 lines
1export type Summary = { tldr: string[]; know: string[]; next: string[] }
2export type View = { summary: Summary; isLatest: boolean }
3export type Status = { text: string; isError: boolean }
4
5declare module 'claude-code' {
6  interface PluginState {
7    tldr: { view: View | null; isCollapsed: boolean; status: Status | null }
8  }
9}
10