Collapses long replies to a one-sentence TL;DR: [ more ] expands one, /tldr turns it off.

Long replies collapse to a one-sentence TL;DR.
Collapses long replies — Any reply text over ~100 words is drawn as a one-sentence TL;DR that Haiku writes from that text alone. [ more ] under it shows the full text, [ less ] collapses it again.
/tldr toggles it — /tldr turns collapsing off or back on, and the choice is kept across sessions. /tldr more and /tldr less expand or collapse the newest reply from the keyboard.
Only the display changes: Claude still writes and reads its full replies.
hooks/register.tsx 201 lines1// tldr as a mod: each long assistant text block is drawn as a one-sentence
2// Haiku TL;DR with [ more ] to expand it. Only the drawing changes; the stored
3// transcript and the model's context are untouched. /tldr toggles it globally.
4import { atom, read, update, memberOf } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6import { GUIDELINES, isLong, lineFor, parseArgs, wordCount } from './lib'
7
8const isOn = atom({ plugin: 'tldr', key: 'isOn' } as const, true)
9const line = atom({ plugin: 'tldr', key: 'line' } as const, '')
10const isExpanded = atom({ plugin: 'tldr', key: 'isExpanded' } as const, false)
11const isLearned = atom({ plugin: 'tldr', key: 'isLearned' } as const, false)
12const isFinal = atom({ plugin: 'tldr', key: 'isFinal' } as const, false)
13
14const FAIL = '!fail:'
15const RETRY_MS = 60_000 // a failed summary is tried again on a redraw this long after
16const MAX_RUNNING = 3
17const KEEP_PER_SESSION = 200
18const KEEP_SESSIONS = 20
19
20// Module state starts over on every reload; $.state and $.store survive it.
21const started = new Set<string>() // ids queued, in flight or summarized this load
22const order: string[] = [] // long blocks in the order first drawn, for /tldr more|less
23const queue: (() => Promise<void>)[] = []
24let running = 0
25let saving: Promise<void> = Promise.resolve()
26
27function pump() {
28 while (running < MAX_RUNNING && queue.length) {
29 const job = queue.shift()!
30 running++
31 job().finally(() => {
32 running--
33 pump()
34 })
35 }
36}
37
38async function save($: EngineInterface, id: string, t: string) {
39 const sid = await $.session.id() // looked up per save: /clear starts a new session
40 const saved = ((await $.store.get('s/' + sid)) ?? {}) as Record<string, string>
41 saved[id] = t
42 const keys = Object.keys(saved)
43 for (const k of keys.slice(0, Math.max(0, keys.length - KEEP_PER_SESSION))) delete saved[k]
44 await $.store.set('s/' + sid, saved)
45 const index = ((await $.store.get('sessions')) ?? []) as string[]
46 if (!index.includes(sid)) {
47 index.push(sid)
48 while (index.length > KEEP_SESSIONS) await $.store.delete('s/' + index.shift())
49 await $.store.set('sessions', index)
50 }
51}
52
53async function summarize($: EngineInterface, id: string, text: string) {
54 let t: string
55 try {
56 const r = await $.model.complete({
57 model: 'haiku',
58 system: GUIDELINES,
59 prompt: '<message>\n' + text + '\n</message>',
60 effort: 'low',
61 timeoutMs: 30000,
62 })
63 t = r.isAnswered ? lineFor(r.text) : FAIL + (r.reason ?? 'no answer')
64 } catch (err) {
65 t = FAIL + 'refused: ' + String(err) // the engine refused to send it (a blocked model, say)
66 }
67 await update($, memberOf(line, { requestId: id }), () => t)
68 await $.ui.log(`tldr ${id}: ${t}`, { to: 'debug' })
69 if (t.startsWith(FAIL)) {
70 $.clock.after(RETRY_MS, () => started.delete(id))
71 return
72 }
73 // Serialized read-modify-writes; a failed save must not stall the ones after it.
74 saving = saving.then(() => save($, id, t)).catch(() => {})
75 await saving
76}
77
78/** Summarize a block's visible text, drawn after its row was stored (isFinal): only then is it whole. */
79function start($: EngineInterface, id: string, text: string) {
80 if (started.has(id)) return
81 started.add(id)
82 queue.push(() => summarize($, id, text).catch(() => {}))
83 pump()
84}
85
86async function setOn($: EngineInterface, value: boolean) {
87 await $.store.set('enabled', value)
88 await update($, isOn, () => value)
89}
90
91/** The person has used [ more ], [ less ] or /tldr: the inline hint retires for good. */
92async function learn($: EngineInterface) {
93 if (await read($, isLearned)) return
94 await $.store.set('learned', true)
95 await update($, isLearned, () => true)
96}
97
98async function setExpanded($: EngineInterface, id: string, value: boolean) {
99 await update($, memberOf(isExpanded, { requestId: id }), () => value)
100 await learn($)
101}
102
103export const register: Register = on => {
104 on('session.start', async ($, e, next) => {
105 const enabled = await $.store.get('enabled')
106 await update($, isOn, () => enabled !== false)
107 const learned = (await $.store.get('learned')) === true
108 await update($, isLearned, () => learned)
109 await $.command.register({ name: 'tldr', description: 'Collapse long replies to one sentence: /tldr toggles; on, off, more, less' })
110 const saved = ((await $.store.get('s/' + (await $.session.id()))) ?? {}) as Record<string, string>
111 for (const [id, t] of Object.entries(saved)) await update($, memberOf(line, { requestId: id }), () => t)
112 return next(e)
113 })
114
115 on('session.append', { door: 'response' }, async ($, e, next) => {
116 const r = await next(e)
117 // The row is stored, so its blocks are whole. Marking it redraws a block already drawn (it may
118 // have been drawn while streaming), and that drawing starts the summary from its visible text.
119 if (!e.agentId && r.uuid && r.message?.type === 'assistant') {
120 await update($, memberOf(isFinal, { requestId: r.uuid }), () => true)
121 }
122 return r
123 })
124
125 on('command.run', { command: 'tldr' }, async ($, e) => {
126 const what = parseArgs(e.args)
127 await learn($)
128 if (what === 'help') return { text: 'Usage: /tldr (toggle), /tldr on, /tldr off, /tldr more, /tldr less' }
129 if (what === 'toggle' || what === 'on' || what === 'off') {
130 const value = what === 'toggle' ? !(await read($, isOn)) : what === 'on'
131 await setOn($, value)
132 return { text: value ? 'tl;dr on: long replies collapse to one sentence; [ more ] expands one.' : 'tl;dr off: replies show in full.' }
133 }
134 if (!(await read($, isOn))) return { text: 'tl;dr is off; /tldr turns it on.' }
135 const wantOpen = what === 'more'
136 for (let i = order.length - 1; i >= 0; i--) {
137 const me = { requestId: order[i]! }
138 const t = await read($, memberOf(line, me))
139 if (!t || t.startsWith(FAIL)) continue // drawn in full anyway
140 if ((await read($, memberOf(isExpanded, me))) !== wantOpen) {
141 await update($, memberOf(isExpanded, me), () => wantOpen)
142 return { text: wantOpen ? 'Expanded the latest collapsed reply.' : 'Collapsed the latest expanded reply.' }
143 }
144 }
145 return { text: wantOpen ? 'No collapsed reply to expand.' : 'No expanded reply to collapse.' }
146 })
147
148 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
149 const text = e.props.text
150 if (e.props.isSummary || !isLong(text) || !(await read($, isOn))) return next(e)
151 const id = e.requestId
152 if (!order.includes(id)) order.push(id)
153 const t = await read($, memberOf(line, e))
154 const fin = await read($, memberOf(isFinal, e))
155 if (fin && (!t || t.startsWith(FAIL))) start($, id, text)
156
157 // No summary and none coming (not stored yet, so maybe streaming, or stored before this
158 // session's mod started): the engine's own drawing, untouched.
159 if (!t && !fin) return next(e)
160
161 const { Box, Text, Button } = $.ui.resolve(e)
162 const indent = e.surface === 'terminal' ? 2 : 0 // under the message text, past the bullet
163 if (t.startsWith(FAIL)) {
164 return (
165 <Box flexDirection="column">
166 {await next(e)}
167 <Box paddingLeft={indent}>
168 <Text dimColor>tl;dr unavailable: {t.slice(FAIL.length)}</Text>
169 </Box>
170 </Box>
171 )
172 }
173 if (await read($, memberOf(isExpanded, e))) {
174 return (
175 <Box flexDirection="column">
176 {await next(e)}
177 <Box marginTop={1} paddingLeft={indent}>
178 <Button key="less" label="less" dimColor onPress={() => setExpanded($, id, false)} />
179 </Box>
180 </Box>
181 )
182 }
183 const bullet = e.surface === 'terminal' ? (e.props.isFirstOfReply ? '⏺ ' : ' ') : ''
184 const learned = await read($, isLearned)
185 return (
186 <Box flexDirection="column" marginTop={1}>
187 <Box flexDirection="row">
188 {bullet ? <Text>{bullet}</Text> : null}
189 <Box flexGrow={1} flexShrink={1}>
190 {t ? <Text>{t}</Text> : <Text dimColor italic>summarizing…</Text>}
191 </Box>
192 </Box>
193 <Box marginTop={1} paddingLeft={indent}>
194 <Button key="more" label={`more · ${wordCount(text)} words`} dimColor onPress={() => setExpanded($, id, true)} />
195 {learned ? null : <Text dimColor> /tldr turns collapsing off</Text>}
196 </Box>
197 </Box>
198 )
199 })
200}
201hooks/lib.ts 36 lines1// Pure helpers, tested with `claude plugin test`.
2
3export const GUIDELINES = `You write the one sentence a reader sees in place of a long message from an AI assistant; the full message stays one click away. The reader asked the assistant for something, maybe hours of work ago, and wants to know how it went.
4
5- One plain sentence, under 30 words, no prefix. Write as the assistant, to the reader: its "I" stays "I", the reader is "you".
6- Lead with the outcome: the answer, result or finding, keeping the message's hedges, tense and who did what.
7- Mention a question or request only if the message actually puts one to the reader, in its own words. Never comment on the message or on what it doesn't ask.
8- If the reader must answer or decide and one sentence can't carry what they need, end by saying what to look at in the full message.
9
10Reply with the sentence only.`
11
12/** Long enough to collapse: over 100 words and more than one non-blank line. */
13export function isLong(text: string): boolean {
14 return wordCount(text) > 100 && text.split('\n').filter(l => l.trim()).length > 1
15}
16
17export function wordCount(text: string): number {
18 return text.split(/\s+/).filter(Boolean).length
19}
20
21/** One line from the model's answer: trimmed, a stray prefix removed, newlines folded. */
22export function lineFor(answer: string): string {
23 return answer
24 .trim()
25 .replace(/^(tl;?dr|summary|one sentence|in one sentence)\s*:\s*/i, '')
26 .replace(/\s*\n+\s*/g, ' ')
27}
28
29/** Parse `/tldr` arguments. */
30export function parseArgs(args: string): 'toggle' | 'on' | 'off' | 'more' | 'less' | 'help' {
31 const a = args.trim().toLowerCase()
32 if (a === '') return 'toggle'
33 if (a === 'on' || a === 'off' || a === 'more' || a === 'less') return a
34 return 'help'
35}
36types/index.d.ts 17 lines1declare module 'claude-code' {
2 interface PluginState {
3 'tldr': {
4 /** Collapsing on (mirrors $.store 'enabled', which persists across sessions). */
5 isOn: boolean
6 /** By message id: the TL;DR, '' while none. */
7 line: StateFamily<string>
8 /** By message id: the person pressed [ more ]. */
9 isExpanded: StateFamily<boolean>
10 /** The person has used [ more ], [ less ] or /tldr (mirrors $.store 'learned'). */
11 isLearned: boolean
12 /** By message id: its row was stored, so the drawn text is whole. */
13 isFinal: StateFamily<boolean>
14 }
15 }
16}
17