SLOPSHOPPER

tldr

/tldr: a short plain-English summary of Claude's last reply, shown in a box above the prompt the model never reads

newbandcommandstatusmodel
v0.1.0no licenseupdated 2026-10-06bzatrok/ClaudeMods/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 ⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ tl;dr │ │ OK │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ tl;dr │ │ OK │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Source 3 files
hooks/register.tsx 178 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { TldrSummary } from '../types'
5
6import {
7  buildPrompt,
8  buildTunePrompt,
9  DEFAULT_LEVEL,
10  isLevel,
11  isWorthSummarising,
12  lastReply,
13  levelRules,
14  LEVELS,
15  parseArgs,
16  systemPrompt,
17  toLines,
18  TUNE_SYSTEM,
19} from './summarize'
20import type { Level } from './summarize'
21
22const COMMAND = 'tldr'
23const LEVEL_KEY = 'level'
24
25/** Persisted: the reading level is a preference, kept across sessions. */
26async function readLevel($: EngineInterface): Promise<Level> {
27  const stored = await $.store.get(LEVEL_KEY)
28  return typeof stored === 'string' && isLevel(stored) ? stored : DEFAULT_LEVEL
29}
30
31/** Per session on purpose: every session starts off, `/tldr auto` turns it on for that session only. */
32const isAuto = atom({ plugin: 'tldr', key: 'isAuto' } as const, false)
33/** The box above the prompt; cleared when the next turn starts so it never describes an older reply. */
34const summary = atom({ plugin: 'tldr', key: 'summary' } as const, null as TldrSummary | null)
35
36/**
37 * The summary is drawn above the prompt, never sent: the model does not read
38 * it and the conversation carries on untouched.
39 */
40async function summarise($: EngineInterface, options: PluginOptions, reply: string, args: string): Promise<void> {
41  // the engine prefixes the plugin name: this reads `tldr: summarising…`
42  $.ui.status('summarising…')
43  let result
44  try {
45    result = await $.model.complete({
46      model: 'haiku',
47      system: systemPrompt(await readLevel($), options),
48      prompt: buildPrompt(reply, args),
49      maxTokens: 600,
50      effort: 'low',
51      timeoutMs: 30_000,
52    })
53  } finally {
54    $.ui.status(undefined)
55  }
56
57  if (!result.isAnswered) {
58    $.ui.log(`tldr: no summary (${result.reason})`)
59    return
60  }
61  await update($, summary, () => toLines(result.text))
62}
63
64/** `options` holds the per-level overrides from `/config`; a change there reloads the module. */
65/**
66 * Sonnet rewrites the level's rules from the feedback: rare, and worth the better wording.
67 * Saved as the plugin's `/config` field (`tldr.<level>`), so it is global and editable there.
68 */
69async function tune($: EngineInterface, options: PluginOptions, level: Level, feedback: string): Promise<void> {
70  $.ui.status(`tuning ${level}…`)
71  let result
72  try {
73    result = await $.model.complete({
74      model: 'sonnet',
75      system: TUNE_SYSTEM,
76      prompt: buildTunePrompt(level, levelRules(level, options), feedback),
77      maxTokens: 400,
78      effort: 'low',
79      timeoutMs: 60_000,
80    })
81  } finally {
82    $.ui.status(undefined)
83  }
84  if (!result.isAnswered) {
85    $.ui.log(`tldr: ${level} not changed (${result.reason})`)
86    return
87  }
88  const rules = result.text.trim()
89  const { deny } = await $.config.set({ key: `tldr.${level}`, value: rules })
90  $.ui.log(deny === undefined ? `tldr: ${level} rules now: ${rules}` : `tldr: ${level} not saved (${deny})`)
91}
92
93export const register: Register = (on, options) => {
94  on('session.start', async ($, e, next) => {
95    await $.command.register({
96      name: COMMAND,
97      description: `TL;DR of the last reply, not sent to the model ("auto" toggles it after every reply; ${LEVELS.join('/')} sets the reading level; "tune <level> <feedback>" rewrites a level's rules; else an extra ask, e.g. "one line")`,
98    })
99
100    return next(e)
101  })
102
103  on('command.run', { command: COMMAND }, async ($, e) => {
104    const parsed = parseArgs(e.args)
105    if (parsed.kind === 'auto') {
106      const enabled = !(await read($, isAuto))
107      await update($, isAuto, () => enabled)
108      return { text: enabled ? 'auto on.' : 'auto off.' }
109    }
110    if (parsed.kind === 'usage') {
111      $.ui.log(`tldr: ${parsed.text}`)
112      return {}
113    }
114    if (parsed.kind === 'tune') {
115      await tune($, options, parsed.level, parsed.feedback)
116      return {}
117    }
118    if (parsed.kind === 'reset') {
119      const { deny } = await $.config.set({ key: `tldr.${parsed.level}`, value: '' })
120      $.ui.log(deny === undefined ? `tldr: ${parsed.level} back to built-in rules` : `tldr: ${parsed.level} not reset (${deny})`)
121      return {}
122    }
123    if (parsed.level !== undefined) {
124      await $.store.set(LEVEL_KEY, parsed.level)
125      $.ui.log(`tldr: level ${parsed.level}`)
126    }
127
128    const reply = lastReply(await $.session.messages())
129    if (reply === undefined) {
130      $.ui.log('tldr: no reply to summarise yet')
131      return {}
132    }
133    await summarise($, options, reply, parsed.ask)
134
135    return {}
136  })
137
138  on('turn.start', async ($, e, next) => {
139    await update($, summary, () => null)
140
141    return next(e)
142  })
143
144  /** Main loop answers only; not awaited, so the turn ends without waiting on haiku. */
145  on('turn.complete', async ($, e, next) => {
146    const result = await next(e)
147    if (e.agentId === undefined && e.reason === 'answer' && isWorthSummarising(e.answer) && (await read($, isAuto))) {
148      void summarise($, options, e.answer, '')
149    }
150
151    return result
152  })
153
154  /** Stacks under whatever else draws in the band (context-bar), never replaces it. */
155  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
156    const lines = await read($, summary)
157    if (e.props.hasSurvey || e.props.isWorking || lines === null) return next(e)
158
159    const { Box, Text } = $.ui.resolve(e)
160    const rest = await next(e)
161
162    return (
163      <Box flexDirection="column">
164        {rest}
165        <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
166          <Text>
167            <Text color="cyan">◆ </Text>
168            <Text bold>tl;dr</Text>
169          </Text>
170          {lines.map(line => (
171            <Text>{line}</Text>
172          ))}
173        </Box>
174      </Box>
175    )
176  })
177}
178
hooks/summarize.ts 110 lines
1import type { SessionMessage } from 'claude-code'
2
3/** Reading levels, simplest first; `/tldr <level>` picks one and it is remembered across sessions. */
4export const LEVELS = ['eli5', 'eli8', 'eli12', 'eli15'] as const
5export type Level = (typeof LEVELS)[number]
6export const DEFAULT_LEVEL: Level = 'eli12'
7
8const LEVEL_RULES: Record<Level, string> = {
9  eli5: 'Write for a 5-year-old: tiny words, one idea per sentence, an everyday comparison instead of any technical term. At most 3 short sentences, no bullets.',
10  eli8: 'Write for an 8-year-old: simple words, short sentences, explain any technical term in a few plain words or leave it out. Formulas and symbols are never copied.',
11  eli12: 'Write for a smart 12-year-old: plain words, technical terms only when needed and then explained in passing. No formulas or symbols; describe what they mean.',
12  eli15: 'Write for a bright 15-year-old: normal vocabulary, key technical terms kept with a short gloss. At most one formula, only if it is the point.',
13}
14
15export function isLevel(word: string): word is Level {
16  return (LEVELS as readonly string[]).includes(word)
17}
18
19/** A level's rules from the plugin's config (`/config`, one field per level); blank keeps the built-in. */
20export function levelRules(level: Level, overrides: Readonly<Record<string, unknown>>): string {
21  const own = overrides[level]
22  return typeof own === 'string' && own.trim() !== '' ? own.trim() : LEVEL_RULES[level]
23}
24
25/** The level only changes the wording: paths, commands and numbers the person must act on are kept at every level. */
26export function systemPrompt(level: Level, overrides: Readonly<Record<string, unknown>> = {}): string {
27  return [
28    'You rewrite one assistant reply as a TL;DR for the person who read it.',
29    levelRules(level, overrides),
30    'Lead with the answer or the outcome in one or two sentences. Then up to 4 bullets with the points that matter most; do not label bullets with fixed headings.',
31    'Keep every file path, command and number the person has to act on, verbatim. Drop everything else.',
32    'Do not add facts. Do not address the assistant. No preamble, no heading.',
33    'Plain text only: no bold, no italics, no headings; bullets start with "- ".',
34  ].join(' ')
35}
36
37/** The newest assistant message with text; tool-only turns have none. */
38export function lastReply(messages: readonly SessionMessage[]): string | undefined {
39  for (let i = messages.length - 1; i >= 0; i--) {
40    const m = messages[i]
41    if (m?.role === 'assistant' && m.text.trim() !== '') return m.text
42  }
43  return undefined
44}
45
46/** Below this a reply is already short enough to read as is. */
47const MIN_CHARS = 400
48
49export function isWorthSummarising(answer: string): boolean {
50  return answer.trim().length >= MIN_CHARS
51}
52
53export type Parsed =
54  | { kind: 'auto' }
55  /** Rewrite a level's rules from feedback; saved to settings. */
56  | { kind: 'tune'; level: Level; feedback: string }
57  /** Back to the built-in rules for a level. */
58  | { kind: 'reset'; level: Level }
59  /** A malformed `tune` / `reset`: what to say instead. */
60  | { kind: 'usage'; text: string }
61  /** `level` set when the first word names one: it is remembered; `ask` is what is left. */
62  | { kind: 'summarise'; level: Level | undefined; ask: string }
63
64const USAGE = `usage: /tldr tune <${LEVELS.join('|')}> <feedback>, /tldr reset <level>`
65
66/**
67 * `/tldr auto` toggles; `/tldr tune eli5 <feedback>` and `/tldr reset eli5` change a level's rules;
68 * `/tldr eli5 [ask]` sets the level, then summarises; else the args are an extra ask.
69 */
70export function parseArgs(args: string): Parsed {
71  const trimmed = args.trim()
72  const [first = '', second = '', ...rest] = trimmed.split(/\s+/)
73  const verb = first.toLowerCase()
74  const level = second.toLowerCase()
75  if (verb === 'auto' && second === '') return { kind: 'auto' }
76  if (verb === 'tune') {
77    const feedback = rest.join(' ')
78    return isLevel(level) && feedback !== '' ? { kind: 'tune', level, feedback } : { kind: 'usage', text: USAGE }
79  }
80  if (verb === 'reset') return isLevel(level) && rest.length === 0 ? { kind: 'reset', level } : { kind: 'usage', text: USAGE }
81  if (isLevel(verb)) return { kind: 'summarise', level: verb, ask: [second, ...rest].join(' ').trim() }
82  return { kind: 'summarise', level: undefined, ask: trimmed }
83}
84
85export const TUNE_SYSTEM = [
86  'You maintain the wording rules a summariser follows at one reading level.',
87  'You get the current rules and feedback from the person who reads the summaries.',
88  'Return the full revised rules: 1 to 4 imperative sentences, addressed to the summariser, under 400 characters.',
89  'Keep what the feedback does not contradict. Do not mention file paths, commands or bullets: other rules cover those.',
90  'Return only the rules, no preamble, no quotes.',
91].join(' ')
92
93export function buildTunePrompt(level: Level, current: string, feedback: string): string {
94  return `<level>${level}</level>\n<current>\n${current}\n</current>\n<feedback>\n${feedback}\n</feedback>`
95}
96
97/** `args` is an optional extra ask (`/tldr one line`), appended as an instruction. */
98export function buildPrompt(reply: string, args: string): string {
99  const extra = args.trim() === '' ? '' : `\n\nAlso: ${args.trim()}`
100  return `<reply>\n${reply}\n</reply>\n\nWrite the TL;DR of this reply.${extra}`
101}
102
103/** Blank lines dropped so the box stays compact. */
104export function toLines(summary: string): string[] {
105  return summary
106    .split('\n')
107    .map(line => line.trimEnd())
108    .filter(line => line.trim() !== '')
109}
110
types/index.d.ts 9 lines
1/** The TL;DR as drawn in the band above the prompt, one entry per line. */
2export type TldrSummary = string[]
3
4declare module 'claude-code' {
5  interface PluginState {
6    tldr: { isAuto: boolean; summary: TldrSummary | null }
7  }
8}
9