SLOPSHOPPER

too-long-didnt-read

Mod: /tldr summarizes the last answer; big answers get a small chart (bars, flow or tree), drawn above the prompt by your-call. Modes off, on, auto, smart

newguardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · too-long-didnt-read
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ too-long-didnt-read │ ⏺ Read(src/auth.ts) │ TL;DR charts need the your-call plugin. │ ⎿ Read 6 lines │ Showing text. │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ╭─────────────────────╮ ⏺ Bash(bun test) │ too-long-didnt-read │ ⎿ 3 pass, 1 fail │ TL;DR OK │ ╰─────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tldr ⎿ too-long-didnt-read: OK ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ too-long-didnt-read: tldr smart
README

too-long-didnt-read

Mod. The command is still /tldr. Depends on your-call, which draws the charts (installing this plugin installs it).

  • /tldr summarizes Claude's last answer as Verdict / Why / Next (one small model call). It answers in the thread as text.
  • Big answers get a chart. In auto and smart mode (smart is the default), after a long answer the summary is sized to fit: a small one (at most 3 lines of 40 columns) is a toast, a bigger one is a chart that your-call draws in the thread under the answer it summarises (≡ TL;DR; SVG on desktop, VS Code and mobile). Nothing goes above the prompt, so it never shares a row with the context bar. The shape is chosen by what the answer says:
  • it compares options with numbers or scores: bars
  • it is an ordered plan or procedure: flow (plan ──▶ build ──▶ ship)
  • anything else: a tree (Verdict, Why with its reasons, Next)

If the model's chart can't be used, it falls back to the same Verdict / Why / Next tree built from a plain summary. Charts stay with their answer in the thread.

  • If your-call is not loaded, it says so once ("TL;DR charts need the your-call plugin") and falls back to a toast. With the option widget: false the summary is always a toast. A toast shows as at most three lines of 40 columns, so it is requested and trimmed to that.

/tldr off|on|auto|smart sets the mode, remembered across sessions:

  • off: nothing automatic.
  • on: every answer uses the short shape (system prompt section).
  • auto: a summary after any answer over 400 characters.
  • smart (default): short shape for substantive answers, plus a summary only for big turns (1,200+ characters, 15+ lines, or 5+ tool calls).

Options: defaultMode, widget. The status line shows the mode as tldr smart.

Source 4 files
hooks/register.tsx 174 lines
1import { atom, update } from 'claude-code'
2import type { Hook, Register } from 'claude-code'
3
4import type { Card } from '../types'
5
6import { fitToast } from './toast'
7import { answerKey, normalizeViz, parseJsonLoose, toastLines, treeFromSummary } from './viz'
8
9const cards = atom({ plugin: 'too-long-didnt-read', key: 'cards' } as const, [] as Card[])
10const MAX_CARDS = 20
11
12export const NEEDS_YOUR_CALL = 'TL;DR charts need the\nyour-call plugin. Showing text.'
13
14export type Mode = 'off' | 'on' | 'auto' | 'smart'
15const MODES: Mode[] = ['off', 'on', 'auto', 'smart']
16type Engine = Parameters<Hook<'session.start'>>[0]
17
18const SHAPE = `Answer shape:
19**Verdict:** one line, the answer or outcome.
20**Why:** 1-3 bullets, only what the reader needs.
21**Next:** the single best next step (or "none").
22No preamble, no recap of the question, no code dumps (reference file:line instead).`
23
24const SECTION: Partial<Record<Mode, string>> = {
25  on: `${SHAPE}\nUse exactly this shape for every answer, max 6 lines. Detail only when the user says "expand".`,
26  smart: `${SHAPE}\nUse this shape for substantive answers; trivial replies can be one line.`,
27}
28
29export const AUTO_MIN = 400
30const SMART_CHARS = 1200
31const SMART_LINES = 15
32const SMART_TOOLS = 5
33
34const status = ($: Engine, mode: Mode) => $.ui.status(mode === 'off' ? undefined : `tldr ${mode}`)
35
36export const parseMode = (a: string): Mode | undefined => ((MODES as string[]).includes(a) ? (a as Mode) : undefined)
37
38export const wantSummary = (mode: Mode, answer: string, tools: number): boolean => {
39  if (mode === 'auto') return answer.length >= AUTO_MIN
40  if (mode !== 'smart') return false
41  return answer.length >= SMART_CHARS || answer.split('\n').length >= SMART_LINES || tools >= SMART_TOOLS
42}
43
44const FULL = (text: string) =>
45  `TL;DR of the message below in the shape:\nVerdict: <one line>\nWhy: <1-3 short bullets>\nNext: <one line or none>\nNo preamble.\n\n${text.slice(0, 10000)}`
46
47// The toast shows three lines of 40 columns, so ask for exactly that instead of cutting a long answer.
48const SHORT = (text: string) =>
49  `Summarize the message below in exactly 3 lines, no labels, no bullets, no quotes:\nline 1: the answer or outcome (at most 32 characters)\nline 2: the main reason (at most 38 characters)\nline 3: the next step, or "nothing to do" (at most 38 characters)\n\n${text.slice(0, 10000)}`
50
51/** The toast text: a TL;DR header on line 1 is not worth a line, so the verdict leads and carries the prefix. */
52export const toastFrom = (summary: string): string => {
53  const lines = summary.split('\n').map(l => l.replace(/^[-*•\d.)\s]+/, '').trim()).filter(Boolean)
54  if (lines.length === 0) return ''
55  return fitToast([`TL;DR ${lines[0]}`, ...lines.slice(1)].join('\n'))
56}
57
58// Pick the shape by content: numbers across options -> bars, an ordered plan -> flow, anything else -> tree.
59const CHART = (text: string) =>
60  `Describe the message below as ONE small chart. Reply with only JSON, no prose. Pick the shape by content:\n- compares options with numbers or scores: {"type":"bars","title":"...","values":{"Option A":8,"Option B":5}} (2-6 options)\n- an ordered procedure or plan: {"type":"flow","title":"...","lanes":[["step 1","step 2","step 3"]]} (2-6 steps)\n- anything else: {"type":"tree","title":"TL;DR","nodes":[{"label":"Verdict: ..."},{"label":"Why","children":[{"label":"..."},{"label":"..."}]},{"label":"Next: ..."}]}\nEvery label at most 36 characters.\n\n${text.slice(0, 10000)}`
61
62const summarize = async ($: Engine, text: string, short = false) => {
63  const r = await $.model
64    .complete({
65      model: 'haiku',
66      maxTokens: 150,
67      prompt: short ? SHORT(text) : FULL(text),
68    })
69    .catch(() => undefined)
70  return r?.isAnswered ? r.text.trim() : undefined
71}
72
73/** A chart for the message, chosen by what it says; a plain tree from the three-line summary if that fails. */
74const summarizeViz = async ($: Engine, text: string) => {
75  const r = await $.model.complete({ model: 'haiku', maxTokens: 400, prompt: CHART(text) }).catch(() => undefined)
76  const viz = r?.isAnswered ? normalizeViz(parseJsonLoose(r.text)) : undefined
77  if (viz) return viz
78  const plain = await summarize($, text, true)
79  return plain ? treeFromSummary(plain) : undefined
80}
81
82const isDrawn = async ($: Engine): Promise<boolean> => (await $.state.get({ plugin: 'your-call', key: 'ready' })).value === true
83
84/** A small summary is a toast; anything bigger is a chart under the answer it summarises. False when no chart could be made. */
85async function showSummary($: Engine, answer: string): Promise<boolean> {
86  const viz = await summarizeViz($, answer)
87  if (!viz) return false
88  const lines = toastLines(viz)
89  if (lines) $.ui.toast(fitToast(lines.join('\n')), { timeoutMs: 20000 })
90  else await update($, cards, list => [...list, { id: Date.now(), key: answerKey(answer), viz }].slice(-MAX_CARDS))
91  return true
92}
93
94async function toastSummary($: Engine, answer: string): Promise<void> {
95  const s = await summarize($, answer, true)
96  const text = s ? toastFrom(s) : ''
97  if (text) $.ui.toast(text, { timeoutMs: 20000 })
98}
99
100export const register: Register = (on, options) => {
101  let mode: Mode = parseMode(String(options.defaultMode ?? '')) ?? 'smart'
102  let last = ''
103  let toldAboutYourCall = false
104  let tools = 0
105
106  on('session.start', async ($, e, next) => {
107    await $.command.register({
108      name: 'tldr',
109      description: '/tldr = TL;DR of last answer; /tldr off|on|auto|smart sets mode',
110    })
111    const saved = await $.store.get('mode')
112    if (typeof saved === 'string' && (MODES as string[]).includes(saved)) mode = saved as Mode
113    status($, mode)
114    return next(e)
115  })
116
117  on('command.run', { command: 'tldr' }, async ($, e) => {
118    const a = e.args.trim().toLowerCase()
119
120    if (a === '') {
121      if (!last) return { text: 'tldr: nothing to summarize yet.' }
122      const s = await summarize($, last)
123      return { text: s ?? 'tldr: summary failed.' }
124    }
125
126    if (parseMode(a)) {
127      mode = a as Mode
128      await $.store.set('mode', mode)
129      status($, mode)
130      return { text: `tldr mode: ${mode}${mode === 'on' || mode === 'smart' ? ' (answer shape applies from next prompt)' : ''}` }
131    }
132
133    return { text: `tldr mode is ${mode}. Usage: /tldr | /tldr off|on|auto|smart` }
134  })
135
136  on('prompt.submit', ($, e, next) => {
137    tools = 0
138    return next(e)
139  })
140
141  on('tool.call', ($, e, next) => {
142    tools += 1
143    return next(e)
144  })
145
146  on('prompt.compose', async ($, e, next) => {
147    const r = await next(e)
148    const text = SECTION[mode]
149    return text ? { sections: [...r.sections, { id: 'tldr:shape', text, scope: 'session' as const }] } : r
150  })
151
152  on('turn.complete', async ($, e, next) => {
153    if (e.reason !== 'answer' || e.answer === '') return next(e)
154
155    last = e.answer
156
157    if (wantSummary(mode, e.answer, tools)) {
158      let done = false
159      if (options.widget !== false) {
160        // The chart is drawn by your-call (a dependency); without it, say so once and fall back to the toast.
161        if (await isDrawn($)) done = await showSummary($, e.answer)
162        else if (!toldAboutYourCall) {
163          toldAboutYourCall = true
164          $.ui.toast(NEEDS_YOUR_CALL, { timeoutMs: 8000 })
165        }
166      }
167      // Widget off, or no chart could be made: the old three-line toast.
168      if (!done) await toastSummary($, e.answer)
169    }
170
171    return next(e)
172  })
173}
174
hooks/toast.ts 61 lines
1// What the engine does with a plugin toast (read from its toast layout code, build 2.1.29x):
2// - the box is at most 44 columns wide; with its round frame the text gets 40 columns, word-wrapped
3// - at most 3 lines are kept; everything after line 2 is joined into line 3 and cut off if it is wider
4// - where the transcript goes to scrollback it is one line on the notification bar
5// So: three lines, each at most 40 columns, the first one carrying the point.
6export const TOAST_COLS = 40
7export const TOAST_LINES = 3
8
9/** Terminal columns a string takes: wide (CJK, emoji) characters count 2. */
10export const colWidth = (s: string): number => {
11  let w = 0
12  for (const ch of s) {
13    const c = ch.codePointAt(0) ?? 0
14    w += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0x1f300 && c <= 0x1faff)) ? 2 : 1
15  }
16  return w
17}
18
19/** Cut a line to `cols` columns, ending in an ellipsis when something was removed. */
20export const clip = (line: string, cols = TOAST_COLS): string => {
21  if (colWidth(line) <= cols) return line
22  let out = ''
23  for (const ch of line) {
24    if (colWidth(out + ch) > cols - 1) break
25    out += ch
26  }
27  return `${out.trimEnd()}…`
28}
29
30/** Text that shows whole in a toast: at most three lines of 40 columns, blank lines dropped. */
31export const fitToast = (text: string): string => {
32  const lines = text
33    .split('\n')
34    .map(l => l.replace(/\s+/g, ' ').trim())
35    .filter(Boolean)
36  const kept = lines.slice(0, TOAST_LINES).map(l => clip(l))
37  if (lines.length > TOAST_LINES) kept[TOAST_LINES - 1] = clip(`${kept[TOAST_LINES - 1].replace(/…$/, '')} …`)
38  return kept.join('\n')
39}
40
41/** How many lines the engine would show for `text`: explicit newlines, then greedy word wrap at 40 columns. */
42export const wrapCount = (text: string, cols = TOAST_COLS): number =>
43  text.split('\n').reduce((n, line) => {
44    let rows = 1
45    let w = 0
46    for (const word of line.split(' ')) {
47      const ww = colWidth(word)
48      if (w === 0) w = ww
49      else if (w + 1 + ww <= cols) w += 1 + ww
50      else {
51        rows += 1
52        w = ww
53      }
54      while (w > cols) {
55        rows += 1
56        w -= cols
57      }
58    }
59    return n + rows
60  }, 0)
61
hooks/viz.ts 89 lines
1// The three chart shapes a summary can take: the same `viz` spec as your-call's charts, which draws them.
2import type { TreeNode, Viz } from '../types'
3
4import { clip, colWidth } from './toast'
5
6const LABEL = 36
7const MAX_ITEMS = 6
8
9const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
10const label = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? clip(v.replace(/\s+/g, ' ').trim(), LABEL) : undefined)
11
12const normNodes = (raw: unknown, depth = 0): TreeNode[] =>
13  (Array.isArray(raw) ? raw : []).slice(0, MAX_ITEMS).flatMap(n => {
14    const l = isObj(n) ? label(n.label) : label(n)
15    if (!l) return []
16    const kids = isObj(n) && depth < 2 ? normNodes(n.children, depth + 1) : []
17    return [kids.length ? { label: l, children: kids } : { label: l }]
18  })
19
20const normBars = (raw: Record<string, unknown>, title: string | undefined): Viz | undefined => {
21  if (!isObj(raw.values)) return undefined
22  const values: Record<string, number> = {}
23  for (const [k, v] of Object.entries(raw.values).slice(0, MAX_ITEMS)) {
24    const key = label(k)
25    if (key && typeof v === 'number' && Number.isFinite(v) && v >= 0) values[key] = v
26  }
27  return Object.keys(values).length >= 2 ? { type: 'bars', title, values } : undefined
28}
29
30const normFlow = (raw: Record<string, unknown>, title: string | undefined): Viz | undefined => {
31  if (!Array.isArray(raw.lanes)) return undefined
32  const lanes = raw.lanes
33    .slice(0, 3)
34    .map(l => (Array.isArray(l) ? l.slice(0, MAX_ITEMS).flatMap(s => label(s) ?? []) : []))
35    .filter(l => l.length >= 2)
36  return lanes.length ? { type: 'flow', title, lanes } : undefined
37}
38
39const normTree = (raw: Record<string, unknown>, title: string | undefined): Viz | undefined => {
40  const nodes = normNodes(raw.nodes)
41  return nodes.length ? { type: 'tree', title, nodes } : undefined
42}
43
44const NORMALIZERS: Record<string, (raw: Record<string, unknown>, title: string | undefined) => Viz | undefined> = {
45  bars: normBars,
46  flow: normFlow,
47  tree: normTree,
48}
49
50/** Whatever JSON the model returned, as a chart that is safe to draw; undefined when it is not one. */
51export const normalizeViz = (raw: unknown): Viz | undefined => {
52  if (!isObj(raw) || typeof raw.type !== 'string') return undefined
53  return NORMALIZERS[raw.type]?.(raw, label(raw.title))
54}
55
56/** Pull the JSON object out of a reply that may wrap it in a code fence or prose. */
57export const parseJsonLoose = (text: string): unknown => {
58  const start = text.indexOf('{')
59  const end = text.lastIndexOf('}')
60  if (start < 0 || end < start) return undefined
61  try {
62    return JSON.parse(text.slice(start, end + 1))
63  } catch {
64    return undefined
65  }
66}
67
68/** The plain three-line summary as a tree: used when the model's chart is unusable. */
69export const treeFromSummary = (summary: string): Viz | undefined => {
70  const lines = summary.split('\n').map(l => l.replace(/^[-*•\d.)\s]+/, '').trim()).filter(Boolean)
71  const nodes = lines.slice(0, 3).flatMap((l, i) => label(`${['Verdict', 'Why', 'Next'][i]}: ${l.replace(/^(verdict|why|next):?\s*/i, '')}`) ?? [])
72  return nodes.length ? { type: 'tree', title: 'TL;DR', nodes: nodes.map(n => ({ label: n })) } : undefined
73}
74
75/** Identifies an answer's text for matching a chart to its message; the same function is in your-call. */
76export const answerKey = (text: string): string => {
77  const t = text.trim()
78  return `${t.length}:${t.slice(0, 60)}`
79}
80
81const flat = (nodes: TreeNode[]): string[] => nodes.flatMap(n => [n.label, ...flat(n.children ?? [])])
82
83/** The chart as toast lines when it is a small tree that fits 3 lines of 40 columns; otherwise undefined (draw it in the thread). */
84export const toastLines = (viz: Viz): string[] | undefined => {
85  if (viz.type !== 'tree') return undefined
86  const lines = flat(viz.nodes).filter(Boolean)
87  return lines.length >= 1 && lines.length <= 3 && lines.every(l => colWidth(l) <= 40) ? lines : undefined
88}
89
types/index.d.ts 16 lines
1export type TreeNode = { label: string; children?: TreeNode[] }
2export type Viz =
3  | { type: 'bars'; title?: string; values: Record<string, number>; max?: number }
4  | { type: 'flow'; title?: string; lanes: string[][] }
5  | { type: 'tree'; title?: string; nodes: TreeNode[] }
6// `key` ties a chart to the answer it summarises (see answerKey), so it draws under that message.
7export type Card = { id: number; key: string; viz: Viz }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'too-long-didnt-read': { cards: Card[] }
12    // Published by your-call (a dependency); only read here.
13    'your-call': { ready: boolean }
14  }
15}
16