SLOPSHOPPER

tldr

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)…

newbandrowscommandmodeltimer
★ 1v0.3.0MITupdated 2026-10-15samuelbfernandes/moddeck/mods/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: tldr · off · colour green ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tldr

Install (Claude Code 2.1.287 or later): /plugin install tldr --marketplace samuelbfernandes/moddeck

What it does

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:

  • picks the sentences you must not skip (a question asked of you, a decision made for you, a warning, a failure, a required action, a number you need), copied word for word. Those sentences are drawn bold inside the reply, so they stand out while you scroll; in the desktop app the paragraph that holds one is also tinted (green by default, /tldr color to change it). Code is never touched.
  • writes a box above the prompt: TL;DR (two or three sentences), ⚑ Needs you, → Next. ▾ folds it to one line, ✕ puts it away until the next long reply.

The call runs after the turn has ended, so nothing waits on it, and nothing is added to the conversation.

Modes

  • highlight: only the must-read sentences are marked in the reply. No box.
  • summary (the default): the marks, plus the TL;DR box above the prompt.

Colour

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.

Use

  • /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.
  • Setting minChars (default 800): shorter replies are left alone.

Cost

One Haiku call per long reply: the reply (up to about 24,000 characters) in, at most 700 tokens out. Off by default.

Limits

  • A small model can miss a sentence or pick a dull one; a must-read sentence it paraphrased instead of copying is dropped (only exact sentences are marked).
  • Marks cover the last 40 picked sentences of the session and go away after /clear.
  • Not verified live: the marks and the box in a real session, and in the desktop app.

Credit

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.

Source 3 files
hooks/register.tsx 239 lines
1// 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}
239
hooks/lib.ts 113 lines
1// 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}
113
types/index.d.ts 34 lines
1/** 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