While switched on, after each long reply a small model picks the sentences you must not skip (decisions, warnings, questions for you, failures, things to do)…

Install (Claude Code 2.1.287 or later): /plugin install tldr --marketplace samuelbfernandes/moddeck
Long replies are easy to skim past the one line that mattered. While tldr is on, each long reply (800 characters or more) goes once to a small model (Haiku), which:
/tldr color to change it). Code is never touched.The call runs after the turn has ended, so nothing waits on it, and nothing is added to the conversation.
In the desktop app the paragraph holding a must-read sentence is tinted. /tldr color <name> picks the tint: red, yellow, olive, green (default), blue, purple or plum. The colours are estimated by eye from a screenshot; check them on your screen.
/tldr highlight · /tldr summary: turn it on in that mode. /tldr on keeps the mode you last chose; /tldr off turns it off./tldr (status) shows the mode. The moddeck side bar has the same choice as a dropdown (off, highlight, summary) under Prompt helpers.minChars (default 800): shorter replies are left alone.One Haiku call per long reply: the reply (up to about 24,000 characters) in, at most 700 tokens out. Off by default.
/clear.The idea (a summary box with "you should know" and "what to do next") comes from claude-mod-tldr by alzerapradisa and mystique by wellavelino; no code was copied.
hooks/register.tsx 239 lines1// tldr: while switched on, after each long reply a small model (Haiku) picks the sentences the reader must not skip and
2// sums the reply up. The sentences are marked inside the reply (in bold) wherever the transcript draws them, and a
3// box above the prompt shows Summary, Needs you and Next. One model call per long reply, run after the turn has ended,
4// so nothing waits on it; nothing is added to the conversation. Off until `/tldr on` or the moddeck switch.
5// FAIL OPEN: if anything here fails, the reply is drawn as it was and the box stays empty.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { TldrDigest, TldrMode } from '../types'
10import { HIGHLIGHT_COLORS, HIGHLIGHT_DEFAULT, SYSTEM, addMarks, isHighlightColor, markText, parseDigest, promptFor, splitBlocks, type HighlightColor } from './lib'
11
12type Dollar = EngineInterface
13
14const COMMAND = 'tldr'
15const MODES: readonly TldrMode[] = ['highlight', 'summary']
16const isOff = atom({ plugin: 'tldr', key: 'isOff' } as const, true)
17const mode = atom({ plugin: 'tldr', key: 'mode' } as const, 'summary' as TldrMode)
18const color = atom({ plugin: 'tldr', key: 'color' } as const, HIGHLIGHT_DEFAULT as HighlightColor)
19const digest = atom({ plugin: 'tldr', key: 'digest' } as const, null as TldrDigest | null)
20const marks = atom({ plugin: 'tldr', key: 'marks' } as const, [] as string[])
21const isFolded = atom({ plugin: 'tldr', key: 'isFolded' } as const, false)
22
23async function isOn($: Dollar): Promise<boolean> {
24 try {
25 return (await $.store.get('on')) === true
26 } catch {
27 return false
28 }
29}
30
31/** The highlight colour the reader last chose (green by default). */
32async function colorOf($: Dollar): Promise<HighlightColor> {
33 try {
34 const c = await $.store.get('color')
35 return isHighlightColor(c) ? c : HIGHLIGHT_DEFAULT
36 } catch {
37 return HIGHLIGHT_DEFAULT
38 }
39}
40
41/** `/tldr color <name>`: the tint of the paragraphs that hold a must-read sentence. */
42async function setColor($: Dollar, word: string): Promise<string> {
43 if (!isHighlightColor(word)) return `tldr · colour "${word.slice(0, 20)}" is not one of ${Object.keys(HIGHLIGHT_COLORS).join(', ')}`
44 try {
45 await $.store.set('color', word)
46 await update($, color, () => word)
47 } catch {
48 return 'tldr · that did not work, nothing changed'
49 }
50 return `tldr · highlight colour · ${word}`
51}
52
53/** The mode the reader last chose (summary by default). */
54async function modeOf($: Dollar): Promise<TldrMode> {
55 try {
56 const m = await $.store.get('mode')
57 return MODES.find(x => x === m) ?? 'summary'
58 } catch {
59 return 'summary'
60 }
61}
62
63async function flip($: Dollar, to: boolean): Promise<string> {
64 try {
65 if (to) await $.store.set('on', true)
66 else await $.store.delete('on')
67 await update($, isOff, () => !to)
68 if (!to) await update($, digest, () => null)
69 } catch {
70 return 'tldr · that did not work, nothing changed'
71 }
72 return to ? `tldr · on · ${MODE_TEXT[await modeOf($)]}` : 'tldr · off'
73}
74
75/** `highlight` or `summary`: turns tldr on in that mode (the switch and the dropdown both use it). */
76async function setMode($: Dollar, to: TldrMode): Promise<string> {
77 try {
78 await $.store.set('mode', to)
79 await $.store.set('on', true)
80 await update($, mode, () => to)
81 await update($, isOff, () => false)
82 } catch {
83 return 'tldr · that did not work, nothing changed'
84 }
85 return `tldr · on · ${MODE_TEXT[to]}`
86}
87
88const MODE_TEXT: Record<TldrMode, string> = {
89 highlight: 'highlight: must-read sentences are marked in the reply, no box',
90 summary: 'summary: must-read sentences are marked, and a box above the prompt sums the reply up (one Haiku call each)',
91}
92
93/** One small-model call over the reply; the digest and its must-read sentences replace the last ones. */
94async function digestReply($: Dollar, reply: string): Promise<void> {
95 try {
96 const r = await $.model.complete({ model: 'haiku', system: SYSTEM, prompt: promptFor(reply), maxTokens: 700, timeoutMs: 60_000 })
97 if (!r.isAnswered) return
98 const d = parseDigest(r.text, reply)
99 if (d === undefined) return
100 await update($, digest, () => d)
101 await update($, isFolded, () => false)
102 if (d.mustRead.length > 0) await update($, marks, list => addMarks(list, d.mustRead))
103 } catch {
104 // fail open: no box this time
105 }
106}
107
108export const register: Register = (on, options) => {
109 const minChars = typeof options.minChars === 'number' && options.minChars >= 200 ? options.minChars : 800
110
111 on('session.start', async ($, e, next) => {
112 try {
113 const isOnNow = await isOn($) // the switch and its mode outlive the session
114 const modeNow = await modeOf($)
115 const colorNow = await colorOf($)
116 await update($, isOff, () => !isOnNow)
117 await update($, mode, () => modeNow)
118 await update($, color, () => colorNow)
119 } catch {
120 // reads as off
121 }
122 try {
123 await $.command.register({ name: COMMAND, description: 'Mark what you must not skip in long replies: /tldr [highlight|summary|on|off]', argumentHint: '[highlight|summary|on|off]', immediate: true })
124 } catch {
125 // the side bar switch still works
126 }
127 return next(e)
128 })
129
130 on('command.run', { command: COMMAND }, async ($, e) => {
131 const word = e.args.trim().toLowerCase()
132 if (word === 'on' || word === 'off') return { text: await flip($, word === 'on') }
133 if (word === 'highlight' || word === 'summary') return { text: await setMode($, word) }
134 if (word.startsWith('color')) return { text: await setColor($, word.slice('color'.length).trim()) }
135 if (word === '' || word === 'status') return { text: ((await isOn($)) ? `tldr · on · ${MODE_TEXT[await modeOf($)]}` : 'tldr · off') + ` · colour ${await colorOf($)}` }
136 return { text: `tldr · unknown word "${e.args.trim().slice(0, 30)}" · use /tldr highlight, /tldr summary, /tldr color <name>, /tldr on or /tldr off` }
137 }).catch(() => ({ text: 'tldr · that did not work, nothing changed' }))
138
139 // A long answer of the main conversation: digest it once the turn is over (a timer, so the turn never waits).
140 on('turn.complete', async ($, e, next) => {
141 const r = await next(e)
142 try {
143 if (e.agentId === undefined && e.reason === 'answer' && !e.isAborted && e.answer.length >= minChars && (await isOn($))) {
144 const reply = e.answer
145 $.clock.after(1, () => void digestReply($, reply))
146 }
147 } catch {
148 // fail open
149 }
150 return r
151 })
152
153 // /clear starts a new conversation: its marks and box go with the old one.
154 on('session.end', async ($, e, next) => {
155 if (e.reason === 'clear') {
156 await update($, digest, () => null).catch(() => undefined)
157 await update($, marks, () => []).catch(() => undefined)
158 }
159 return next(e)
160 })
161
162 // The reply itself: each must-read sentence in bold, so it stands out while scrolling.
163 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
164 if (!(await isOn($))) return next(e)
165 const list = await read($, marks)
166 const marked = markText(e.props.text, list)
167 if (marked === undefined) return next(e)
168 // The terminal draws the rewritten markdown (in bold). The app also tints each paragraph holding a must-read
169 // sentence pale yellow: the reply is drawn here paragraph by paragraph, the marked ones on the tint.
170 if (e.surface !== 'desktop') return next({ ...e, props: { ...e.props, text: marked } })
171 const { Box, Markdown } = $.ui.resolve(e)
172 const tint = HIGHLIGHT_COLORS[await read($, color)]
173 return (
174 <Box key="tldr-reply" flexDirection="column" gap={1}>
175 {splitBlocks(marked).map((block, i) =>
176 list.some(m => block.includes(`**${m}**`)) ? (
177 <Box key={`tldr-p${i}`} backgroundColor={tint}>
178 <Markdown text={block} />
179 </Box>
180 ) : (
181 <Markdown key={`tldr-p${i}`} text={block} />
182 ),
183 )}
184 </Box>
185 )
186 }).catch(($, e, next) => next(e))
187
188 // The box above the prompt: Summary, Needs you, Next; ▾ folds it to one line, ✕ puts it away until the next reply.
189 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
190 const d = await read($, digest)
191 if (d === null || !(await isOn($)) || (await read($, mode)) !== 'summary' || e.props.hasSurvey) return next(e)
192 const { Box, Button, Text } = $.ui.resolve(e)
193 const folded = await read($, isFolded)
194 const head = (
195 <Box key="tldr-head" columnGap={1}>
196 <Button key="tldr:fold" plain onPress={() => void update($, isFolded, f => !f)}>
197 <Text bold color="suggestion">{folded ? '▸ TL;DR' : '▾ TL;DR'}</Text>
198 </Button>
199 <Box flexGrow={1} flexShrink={1}>
200 <Text wrap={folded ? 'truncate-end' : 'wrap'}>{d.summary}</Text>
201 </Box>
202 <Button key="tldr:close" plain onPress={() => void update($, digest, () => null)}>
203 <Text dimColor>✕</Text>
204 </Button>
205 </Box>
206 )
207 const line = (key: string, label: string, items: string[], color?: string) =>
208 items.length > 0 && (
209 <Box key={key} columnGap={1}>
210 <Text bold color={color}>{label}</Text>
211 <Box flexShrink={1}>
212 <Text wrap="wrap">{items.join(' · ')}</Text>
213 </Box>
214 </Box>
215 )
216 const box = (
217 <Box key="tldr-box" flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
218 {head}
219 {!folded && (line('tldr-needs', '⚑ Needs you', d.needs, 'warning') as never)}
220 {!folded && (line('tldr-next', '→ Next', d.next) as never)}
221 </Box>
222 )
223 let below: unknown
224 try {
225 below = await next(e)
226 } catch {
227 below = undefined
228 }
229 return below !== null && typeof below === 'object' ? (
230 <Box key="tldr-band" flexDirection="column">
231 {box}
232 {below as never}
233 </Box>
234 ) : (
235 box
236 )
237 }).catch(($, e, next) => next(e))
238}
239hooks/lib.ts 113 lines1// tldr: pure helpers. The prompt to the small model, reading its answer, and marking the must-read sentences in a reply.
2import type { TldrDigest } from '../types'
3
4export const MAX_MARKS = 40 // sentences kept for marking, across recent replies
5export const MAX_REPLY_CHARS = 24_000 // what the small model is shown of a very long reply
6export const MARK = '⚑'
7/**
8 * The tint behind a paragraph that holds a must-read sentence, in the app. The colours are the ones of the highlighter
9 * sample (estimated by eye from the screenshot); green is the default. As #RRGGBBAA, at 30% opacity.
10 */
11export const HIGHLIGHT_COLORS = {
12 red: '#FF5C5C4D',
13 yellow: '#FFE0664D',
14 olive: '#A8A82A4D',
15 green: '#66BB6A4D',
16 blue: '#6F7CFF4D',
17 purple: '#9C6BD64D',
18 plum: '#5C1F5C4D',
19} as const
20export type HighlightColor = keyof typeof HIGHLIGHT_COLORS
21export const HIGHLIGHT_DEFAULT: HighlightColor = 'green'
22export const HIGHLIGHT = HIGHLIGHT_COLORS[HIGHLIGHT_DEFAULT]
23export const isHighlightColor = (v: unknown): v is HighlightColor => typeof v === 'string' && v in HIGHLIGHT_COLORS
24
25export const SYSTEM = [
26 'You help a busy reader skim a coding assistant\'s reply.',
27 'Answer with JSON only: {"summary": string, "needs": string[], "next": string[], "mustRead": string[]}.',
28 'summary: two or three plain sentences on what was done or found.',
29 'needs: decisions, questions, warnings or failures that need the reader (empty if none). Short, concrete.',
30 'next: what happens next or what the reader should do (at most 3).',
31 'mustRead: at most 5 sentences the reader must not skip, COPIED WORD FOR WORD from the reply (a question asked of them, a decision made for them, a warning, a failure, a required action, a number they need). No paraphrase.',
32].join('\n')
33
34export const promptFor = (reply: string): string =>
35 `The reply:\n\n${reply.length > MAX_REPLY_CHARS ? `${reply.slice(0, MAX_REPLY_CHARS)}\n[cut]` : reply}`
36
37const strings = (v: unknown, max: number): string[] =>
38 Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim()).slice(0, max) : []
39
40/** The model's JSON (it may wrap it in a fence); mustRead keeps only sentences found in the reply as written. */
41export function parseDigest(raw: string, reply: string): TldrDigest | undefined {
42 const body = raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1)
43 let v: Record<string, unknown>
44 try {
45 v = JSON.parse(body) as Record<string, unknown>
46 } catch {
47 return undefined
48 }
49 const summary = typeof v.summary === 'string' ? v.summary.trim() : ''
50 if (summary === '') return undefined
51 const mustRead = strings(v.mustRead, 5)
52 .map(s => s.replace(/^[-*>\s]+/, '').replace(/\*\*/g, ''))
53 .filter(s => s.length >= 12 && reply.includes(s))
54 return { summary, needs: strings(v.needs, 5), next: strings(v.next, 3), mustRead }
55}
56
57/** Newest first, no repeats, at most MAX_MARKS. */
58export const addMarks = (list: readonly string[], fresh: readonly string[]): string[] =>
59 [...fresh, ...list.filter(s => !fresh.includes(s))].slice(0, MAX_MARKS)
60
61/**
62 * The block's markdown with each must-read sentence bolded. A sentence inside a code fence or inline code is
63 * left alone, so code stays exact; so is one already bold. Undefined when nothing changes.
64 */
65export function markText(text: string, marks: readonly string[]): string | undefined {
66 if (marks.length === 0) return undefined
67 let out = text
68 let changed = false
69 for (const m of marks) {
70 let from = 0
71 for (;;) {
72 const at = out.indexOf(m, from)
73 if (at < 0) break
74 const isMarked = out.slice(Math.max(0, at - 2), at) === '**'
75 if (isInCode(out, at) || isMarked) {
76 from = at + m.length
77 continue
78 }
79 const wrapped = `**${m}**`
80 out = out.slice(0, at) + wrapped + out.slice(at + m.length)
81 from = at + wrapped.length
82 changed = true
83 }
84 }
85 return changed ? out : undefined
86}
87
88/** Inside a ``` fence or a `span` on its line. */
89function isInCode(text: string, at: number): boolean {
90 const before = text.slice(0, at)
91 if ((before.match(/^```/gm) ?? []).length % 2 === 1) return true
92 const line = before.slice(before.lastIndexOf('\n') + 1)
93 return (line.match(/`/g) ?? []).length % 2 === 1
94}
95
96/** The reply cut at blank lines into paragraphs, never inside a ``` fence, so each can be drawn (and tinted) on its own. */
97export function splitBlocks(text: string): string[] {
98 const out: string[] = []
99 let cur: string[] = []
100 let inFence = false
101 for (const line of text.split('\n')) {
102 if (/^```/.test(line)) inFence = !inFence
103 if (!inFence && line.trim() === '' && cur.length > 0) {
104 out.push(cur.join('\n'))
105 cur = []
106 continue
107 }
108 if (!(line.trim() === '' && cur.length === 0)) cur.push(line)
109 }
110 if (cur.length > 0) out.push(cur.join('\n'))
111 return out
112}
113types/index.d.ts 34 lines1/** What the small model made of the last long reply. */
2export type TldrDigest = {
3 /** Two or three plain sentences. */
4 summary: string
5 /** Decisions, questions or warnings that need the reader. */
6 needs: string[]
7 /** What happens next, or what the reader should do. */
8 next: string[]
9 /** Sentences copied word for word from the reply that must not be skipped; each one is marked in the transcript. */
10 mustRead: string[]
11}
12
13/** What the reader sees while on: the must-read sentences only, or those plus the TL;DR box above the prompt. */
14export type TldrMode = 'highlight' | 'summary'
15
16declare module 'claude-code' {
17 interface PluginState {
18 tldr: {
19 /** True while off (it ships off). The persisted switch is `$.store` 'on'; mirrored for the moddeck side bar. */
20 isOff: boolean
21 /** The mode while on (`summary` ships as the default). Persisted as `$.store` 'mode'; mirrored for the moddeck side bar. */
22 mode: TldrMode
23 /** The tint of the paragraphs that hold a must-read sentence in the app (green by default). Persisted as `$.store` 'color'. */
24 color: 'red' | 'yellow' | 'olive' | 'green' | 'blue' | 'purple' | 'plum'
25 /** The last long reply's digest; null before the first one, after /clear, or once dismissed. */
26 digest: TldrDigest | null
27 /** Must-read sentences of this session's recent replies, marked wherever the transcript draws them. */
28 marks: string[]
29 /** The box above the prompt is folded to one line. */
30 isFolded: boolean
31 }
32 }
33}
34