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

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

| Feature | What it does |
|---|---|
| TLDR; | 2-3 sentence conclusion of the reply |
| You Should Know | Key facts, warnings, decisions, paths, commands |
| What To Do Next | Concrete next steps for you |
| Follows scroll | Scroll up: box summarizes the message cut at the bottom of the view |
| Collapse | Press t to shrink box to one line, t again to expand |
| Cache | Each summary made once per session, then reused |
| Resume | claude --resume shows summary of last reply |
| Skips noise | Ignores short replies, subagent turns, interrupted turns |
| Status line | Shows why a reply was skipped or why summarizing failed |
Summaries made by haiku: fast and cheap.
git clone https://github.com/alzerapradisa/claude-mod-tldr.git ~/.claude/mods/tldr
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"
Quit and start claude again.
Ask something with a long answer:
Explain how this project is structured
When the reply finishes:
summarizing latest response (haiku)...t to collapse or expand itScroll 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.
git -C ~/.claude/mods/tldr pull
Restart Claude Code.
Set options in ~/.claude/settings.json under pluginConfigs:
{
"pluginConfigs": {
"tldr@inline": {
"options": {
"minChars": 600,
"showKnow": true,
"showNext": false
}
}
}
}
| Option | Type | Default | What |
|---|---|---|---|
minChars | number | 800 | Only summarize replies at least this long |
showKnow | boolean | true | Show "You Should Know" section |
showNext | boolean | true | Show "What To Do Next" section |
showNext: false - TLDR; line then covers what to do too.
Restart Claude Code after changing config.
| Status line says | Meaning |
|---|---|
skipped: reply N chars, under M | Reply 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 |
| Path | What |
|---|---|
hooks/register.tsx | Mod logic and UI |
hooks/register.test.tsx | Tests |
types/index.d.ts | State types |
.claude-plugin/plugin.json | Manifest and config schema |
hooks/register.tsx 304 lines1import { 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}
304types/index.d.ts 10 lines1export 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