SLOPSHOPPER

tldr

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

newrowscommandmodeltimer
★ 26v0.1.5no licenseupdated 2026-10-09Crazytieguy/alignment-hive/plugins/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 ⎿ tldr: tl;dr off: replies show in full. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tldr

Long replies collapse to a one-sentence TL;DR.

What This Plugin Does

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.

Source 3 files
hooks/register.tsx 201 lines
1// 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}
201
hooks/lib.ts 36 lines
1// 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}
36
types/index.d.ts 17 lines
1declare 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