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

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.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:plan ──▶ build ──▶ ship)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.
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:
Options: defaultMode, widget. The status line shows the mode as tldr smart.
hooks/register.tsx 174 lines1import { 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}
174hooks/toast.ts 61 lines1// 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)
61hooks/viz.ts 89 lines1// 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}
89types/index.d.ts 16 lines1export 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