Highlights in yellow the few paragraphs of a long reply worth reading, says the reply in one sentence, and puts an on/off button above the prompt

Jay's Claude Code mods for long conversations: read the part that matters, and keep it when you compact. Page: https://jaygptpro.com/context-mods/
Small add-ons that change how Claude Code looks and works: a button above the prompt, and a yellow highlight on the part of a long reply worth reading. Each one is a Claude Code mod (a plugin with function hooks) packed inside a skill folder, so one command installs it and Claude Code loads it by itself in every new conversation.
| Mod | What you get | Costs |
|---|---|---|
TL;DR (tldr-highlight) | After a long reply, the paragraphs worth reading turn yellow and a notification says the reply in one sentence, in your language. A TL;DR button turns it on and off. | One short Haiku call per long reply, from your Claude plan |
| Smart PreCompact | A context meter above the prompt and a Smart compact button that tells the compact what to keep. Lives in its own repo: JayGPTPro/smart-precompact. | One call when you compact |
Installed together, they can share one row above the prompt in the desktop app.
Open Claude Code and paste the prompt for what you want. Claude runs the install, checks it, and tells you what to expect.
Then open a new conversation. Mods load only when a conversation starts.
You need Node.js (for npx):
npx skills add JayGPTPro/context-mods --skill tldr-highlight -g -a claude-code
Smart PreCompact installs from its own repo:
npx skills add JayGPTPro/smart-precompact -g -a claude-code
Without Node.js: download the repo as a zip and copy skills/tldr-highlight into ~/.claude/skills/ (Windows: %USERPROFILE%\.claude\skills\). Keep the hidden .claude-plugin folder inside it.
When a reply runs past about 150 words, a small model reads it and picks at most three paragraphs: the direct answer, anything waiting on you, and anything that failed or was not checked. They get a yellow background: past the first one, no more than a third of the reply, and never code or a lone heading. The same call writes the reply as one sentence in the language you write in, shown as a notification. ↩ No TL;DR turns it off and the choice is kept; TL;DR turns it back on. The highlight is drawn in the desktop app. A terminal shows the button and the sentence.
Only the drawing changes. The conversation, and what Claude reads, stay as written. The call to the model goes through your own Claude Code login, like the conversation itself.
claude plugin disable tldr@skills-dir
To remove it:
npx skills remove tldr-highlight -g
Never keep a backup copy of a mod's folder inside ~/.claude/skills: Claude Code would load it twice.
claude --debug writes the engine's notes about each mod to the debug log, on lines that start with the mod's name.The mod is a skill folder in skills/: SKILL.md, .claude-plugin/plugin.json, hooks/ (register.tsx, and panel.ts, the shared row) and tests/. dev/test-all.sh runs every check: the mod's claude plugin test and claude plugin validate, and the shared row in every order the mod and Smart PreCompact can load in. It runs on Mac and Linux.
Built by Jay. An independent project, not an Anthropic product.
hooks/register.tsx 388 lines1// TL;DR: when a reply is long, a small model picks the few paragraphs worth reading and they are
2// drawn on a yellow background; a notification says the reply in one sentence, and a button above
3// the prompt turns it on and off. Only the drawing changes: the transcript, and what Claude reads,
4// stay as written.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, RenderChildren, SessionMessage } from 'claude-code'
7
8import type { Brief } from '../types'
9import { DIVIDER, ROW_KEY, buttonPx, seat } from './panel'
10
11const isOn = atom({ plugin: 'tldr', key: 'isOn' } as const, true)
12const isBusy = atom({ plugin: 'tldr', key: 'isBusy' } as const, false)
13const again = atom({ plugin: 'tldr', key: 'again' } as const, false)
14const brief = atom({ plugin: 'tldr', key: 'brief' } as const, null)
15const lastRead = atom({ plugin: 'tldr', key: 'lastRead' } as const, null)
16const MARKED = { plugin: 'tldr', key: 'marked' } as const
17
18// The small model: one short call per long reply, so it costs little of the person's usage.
19const MODEL = 'haiku'
20// A reply counts as long from this many characters of prose (about 150 words).
21const LONG_CHARS = 900
22// At most this many paragraphs, and this share of the reply, are highlighted.
23const MAX_MARKS = 3
24const MAX_SHARE = 0.3
25// See-through yellow: reads as a highlighter on a light page and on a dark one.
26const HIGHLIGHT = 'rgba(255, 213, 0, 0.28)'
27// How much of the person's last message the model sees, to tell its language.
28const SAMPLE_CHARS = 400
29
30const SYSTEM = [
31 'You help a busy person read long replies from their AI coding assistant. They skim, and miss the important part.',
32 'You get one reply split into numbered paragraphs, and a sample of the last message the person wrote. Pick the paragraphs they must read, at most 3:',
33 '1. the direct answer or result, 2. anything waiting on their decision or action, 3. anything that failed, is risky or was not verified.',
34 'Pick fewer when fewer matter. Never pick a code block, a lone heading, or background explanation.',
35 'Also write "tldr": the whole reply in one plain everyday sentence of at most 15 words, in the language the person wrote their sample in (not the language of the reply).',
36 'If something waits on the person, the tldr starts with the words for "Waiting on you:" in that language.',
37 'Never use an em dash or an en dash.',
38 'Answer with JSON only, nothing before or after: {"tldr": "...", "marks": [2, 5]}',
39].join('\n')
40
41const MARKS_ALL = /[]/g
42// A text whose first letter is Hebrew or Arabic, markdown openers and direction marks aside.
43const RTL_FIRST = /^[\s#>*\-+\d.)]*[\u0590-\u08FF]/
44const FENCE = /^\s*(```|~~~)/
45const REMINDERS = /<system-reminder>[\s\S]*?<\/system-reminder>/g
46
47/** One id for a paragraph, the same before and after another mod adds direction marks to it. */
48const keyOf = (text: string) => {
49 const flat = text.replace(MARKS_ALL, '').replace(/\s+/g, ' ').trim()
50 let hash = 5381
51 for (let i = 0; i < flat.length; i += 1) hash = (Math.imul(hash, 33) + flat.charCodeAt(i)) | 0
52 return `${flat.length.toString(36)}-${(hash >>> 0).toString(36)}`
53}
54
55/** The markdown cut at blank lines. A code block stays whole, blank lines and all, and closes only with
56 * its own kind of fence; an indented paragraph stays with the list item it continues. */
57export const paragraphs = (markdown: string) => {
58 const parts: string[] = []
59 let current: string[] = []
60 let fence: string | null = null
61 const end = () => {
62 if (current.some(line => line.trim() !== '')) {
63 const text = current.join('\n')
64 const last = parts[parts.length - 1]
65 const isListItem = last !== undefined && /^\s*(?:[-*+]|\d+[.)])\s/m.test(last) && !FENCE.test(last)
66 if (/^\s{2,}\S/.test(text) && isListItem) parts[parts.length - 1] = `${last}\n\n${text}`
67 else parts.push(text)
68 }
69 current = []
70 }
71 for (const line of markdown.split('\n')) {
72 const opened = FENCE.exec(line)?.[1]
73 if (opened !== undefined && (fence === null || fence === opened)) fence = fence === null ? opened : null
74 if (fence === null && opened === undefined && line.trim() === '') {
75 end()
76 continue
77 }
78 current.push(line)
79 }
80 end()
81 return parts
82}
83
84/** The text without code, links and paths: what counts as prose. */
85const prose = (text: string) =>
86 text
87 .replace(/```[\s\S]*?```/g, ' ')
88 .replace(/`[^`\n]*`/g, ' ')
89 .replace(/https?:\/\/\S+/g, ' ')
90 .replace(MARKS_ALL, '')
91
92/** A paragraph worth highlighting: not code, not a lone heading, not a rule. */
93const isMarkable = (part: string) => !FENCE.test(part) && !/^\s*#{1,6}\s[^\n]*$/.test(part) && !/^\s*([-*_]\s*){3,}$/.test(part)
94
95/** What the person typed, for a prompt row; null for tool results, reminders and notifications. */
96const typed = (row: SessionMessage) => {
97 if (row.role !== 'user' || (row.toolResults?.length ?? 0) > 0) return null
98 const text = row.text.replace(/^(\s*<system-reminder>[\s\S]*?<\/system-reminder>)+/, '').trim()
99 return text !== '' && !/^<(?!pasted_content)/.test(text) && !text.startsWith('Caveat:') && !text.startsWith('[Request interrupted') ? text : null
100}
101
102/** The text blocks of the last reply (every assistant row after the last thing the person sent, a slash
103 * command included), and the last message they typed, for its language. */
104const lastReply = (rows: readonly SessionMessage[]) => {
105 const blocks: string[] = []
106 let asked: string | null = null
107 let i = rows.length - 1
108 for (; i >= 0; i -= 1) {
109 const row = rows[i]
110 if (row === undefined) continue
111 if (row.role === 'user') {
112 // A tool result, or a bare reminder, sits inside the reply; anything else ends it.
113 if ((row.toolResults?.length ?? 0) > 0 || row.text.replace(REMINDERS, '').trim() === '') continue
114 asked = typed(row)
115 i -= 1
116 break
117 }
118 const text = row.text.trim()
119 if (text !== '') blocks.unshift(text)
120 }
121 // A command ended the reply: the language comes from the last message typed before it.
122 for (; asked === null && i >= 0; i -= 1) {
123 const row = rows[i]
124 if (row !== undefined) asked = typed(row)
125 }
126 return { blocks, asked: asked ?? '' }
127}
128
129/** The person's words alone, for telling their language: no pasted text, reminders or code. */
130const sampleOf = (asked: string) =>
131 prose(asked.replace(/<pasted_content[\s\S]*?<\/pasted_content>/g, ' ').replace(REMINDERS, ' '))
132 .replace(/\s+/g, ' ')
133 .trim()
134 .slice(0, SAMPLE_CHARS)
135
136/** No em or en dashes in the sentence; the model can still slip one in. */
137const withoutDashes = (text: string) => text.replace(/(\d)[ \t]*[–—][ \t]*(\d)/g, '$1-$2').replace(/[ \t]*[–—][ \t]*/g, ', ')
138
139/** The model's JSON, or null when it sent something else. The shortest object first: a brace in words
140 * after the JSON would otherwise swallow it. */
141export const parseAnswer = (text: string): { tldr: string; marks: number[] } | null => {
142 for (const pattern of [/\{[\s\S]*?\}/, /\{[\s\S]*\}/]) {
143 const json = pattern.exec(text)?.[0]
144 if (!json) return null
145 try {
146 const value = JSON.parse(json) as { tldr?: unknown; marks?: unknown }
147 const tldr = typeof value.tldr === 'string' ? withoutDashes(value.tldr.trim()) : ''
148 const marks = Array.isArray(value.marks) ? value.marks.filter((n): n is number => Number.isInteger(n)) : []
149 return { tldr, marks }
150 } catch {
151 continue
152 }
153 }
154 return null
155}
156
157/** The marks kept: markable, in range, at most MAX_MARKS, and together at most MAX_SHARE of the reply (the first one always). */
158export const keepMarks = (parts: readonly string[], marks: readonly number[]) => {
159 const total = parts.reduce((sum, part) => sum + prose(part).length, 0)
160 const kept: number[] = []
161 let used = 0
162 for (const n of marks) {
163 const part = parts[n - 1]
164 if (part === undefined || !isMarkable(part) || kept.includes(n)) continue
165 const size = prose(part).length
166 if (kept.length >= MAX_MARKS || (kept.length > 0 && used + size > total * MAX_SHARE)) continue
167 kept.push(n)
168 used += size
169 }
170 return kept
171}
172
173/** Reads the last reply once: a long one goes to the model, and every paragraph of the reply gets its
174 * flag set, picked or not, so a line repeated from an earlier reply is yellow only where it was picked. */
175const readReply = async ($: EngineInterface) => {
176 const reply = lastReply(await $.session.messages())
177 const parts = reply.blocks.flatMap(paragraphs)
178 const size = parts.reduce((sum, part) => sum + prose(part).length, 0)
179 const replyId = keyOf(parts.join('\n\n'))
180 if ((await read($, lastRead)) === replyId) return
181 if (size < LONG_CHARS) {
182 for (const part of parts) await update($, atom({ ...MARKED, id: keyOf(part) }, false), () => false)
183 await update($, lastRead, () => replyId)
184 await update($, brief, () => null)
185 return
186 }
187 const sample = sampleOf(reply.asked)
188 const numbered = parts.map((part, i) => `[${i + 1}]\n${part.replace(MARKS_ALL, '')}`).join('\n\n')
189 const prompt = `The person's last message (for its language only):\n<<<${sample || '(none)'}>>>\n\nThe reply:\n\n${numbered}`
190 const answered = await $.model.complete({ model: MODEL, system: SYSTEM, prompt, maxTokens: 400, effort: 'low', timeoutMs: 60_000 })
191 const answer = answered.isAnswered ? parseAnswer(answered.text) : null
192 if (answer === null) return
193 const kept = keepMarks(parts, answer.marks)
194 for (const [i, part] of parts.entries()) await update($, atom({ ...MARKED, id: keyOf(part) }, false), () => kept.includes(i + 1))
195 await update($, lastRead, () => replyId)
196 const next: Brief = { tldr: answer.tldr, marked: kept.length }
197 await update($, brief, () => next)
198 // Turned off while the model read: the marks wait for the button, and no sentence shows.
199 // The notification shows under the mod's name, "tldr": the sentence alone says the rest.
200 if (answer.tldr !== '' && (await read($, isOn))) $.ui.toast(answer.tldr, { timeoutMs: 15_000 })
201}
202
203/** One read at a time. A trigger that lands during a read asks for one more read after it, so a
204 * reply that arrived meanwhile is not skipped. */
205const mark = async ($: EngineInterface) => {
206 if (!(await read($, isOn))) return
207 let mine = false
208 await update($, isBusy, held => {
209 mine = !held
210 return true
211 })
212 if (!mine) {
213 await update($, again, () => true)
214 return
215 }
216 try {
217 do {
218 await update($, again, () => false)
219 await readReply($)
220 } while (await read($, again))
221 } finally {
222 await update($, isBusy, () => false)
223 }
224 // A trigger that landed between the last check and the release: one more read.
225 if (await read($, again)) await mark($)
226}
227
228const toggle = async ($: EngineInterface) => {
229 const on = !(await read($, isOn))
230 await update($, isOn, () => on)
231 await $.store.set('isOn', on).catch(() => undefined)
232 // Turned on with a long reply already on screen: mark it now, not only from the next reply on.
233 if (on) await mark($).catch(() => undefined)
234}
235
236/** The words a drawing carries: its strings and its text props. The app's own drawing of a reply is a
237 * reference (`{ type: 'engine', ref }`) and carries none. */
238const wordsOf = (tree: unknown, out: string[] = []): string[] => {
239 if (typeof tree === 'string') out.push(tree)
240 else if (Array.isArray(tree)) tree.forEach(t => wordsOf(t, out))
241 else if (tree !== null && typeof tree === 'object') {
242 const node = tree as { props?: { text?: unknown }; children?: unknown }
243 if (typeof node.props?.text === 'string') out.push(node.props.text)
244 wordsOf(node.children, out)
245 }
246 return out
247}
248
249/** A node with this key anywhere in the drawing. */
250const hasKey = (tree: unknown, key: string): boolean => {
251 if (Array.isArray(tree)) return tree.some(t => hasKey(t, key))
252 if (tree === null || typeof tree !== 'object') return false
253 const node = tree as { props?: { key?: unknown }; children?: unknown }
254 return node.props?.key === key || hasKey(node.children, key)
255}
256
257/** A drawing that still holds a paragraph's words: its first line, direction marks aside. A drawing with
258 * no words at all is the app's own, drawn from the text this hook passed down, so it holds them; unless a
259 * mod beneath drew the reply in other words (a translation) and said so with a node keyed `translated`. */
260const holds = (tree: unknown, part: string) => {
261 if (hasKey(tree, 'translated')) return false
262 const line = (part.replace(MARKS_ALL, '').split('\n').find(l => l.trim() !== '') ?? '').trim().slice(0, 40)
263 if (line === '') return true
264 const words = wordsOf(tree).join('\n').replace(MARKS_ALL, '')
265 return words === '' || words.includes(line)
266}
267
268export const register: Register = on => {
269 on('session.start', async ($, e, next) => {
270 const started = await next(e)
271 await update($, isBusy, () => false)
272 await update($, again, () => false)
273 if ((await $.store.get('isOn').catch(() => undefined)) === false) await update($, isOn, () => false)
274 // A reload lands at the end of a turn, where the reply would have been read: read it now.
275 $.clock.after(0, () => void mark($).catch(() => undefined))
276 return started
277 })
278
279 on('turn.complete', async ($, e, next) => {
280 const done = await next(e)
281 // The reply is already on screen; reading it here, not in a timer, so a reload cannot drop it.
282 if (e.agentId === undefined && e.reason === 'answer') await mark($).catch(() => undefined)
283 return done
284 })
285
286 // A /clear or a /resume in place swaps the conversation without a session.start: what was read
287 // belongs to the old one.
288 on('session.end', async ($, e, next) => {
289 const done = await next(e)
290 if (e.reason === 'clear' || e.reason === 'resume') {
291 await update($, lastRead, () => null)
292 await update($, brief, () => null)
293 }
294 return done
295 })
296
297 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
298 // The terminal keeps its own drawing: a see-through color is a web thing.
299 if (e.surface === 'terminal' || e.props.isSummary || !(await read($, isOn))) return next(e)
300 const parts = paragraphs(e.props.text)
301 const flags: boolean[] = []
302 for (const part of parts) flags.push(await read($, atom({ ...MARKED, id: keyOf(part) }, false)))
303 if (!flags.some(Boolean)) return next(e)
304
305 // The drawing beneath first: a mod there (a translation, say) may draw the reply in other
306 // words, and then that drawing wins and nothing is highlighted.
307 const whole = await next(e)
308 const firstMarked = parts[flags.indexOf(true)] ?? ''
309 if (!holds(whole, firstMarked)) return whole
310
311 const { Box, Markdown } = $.ui.resolve(e)
312 // A group drawn here as markdown. One that opens in a right-to-left script is set against the
313 // right edge: a plugin's own drawing may not follow the text's direction the way the app's does.
314 const own = (text: string, i: number) => {
315 const piece = <Markdown key={`t${i}`} text={text} />
316 return RTL_FIRST.test(text) ? (
317 <Box key={`r${i}`} alignItems="flex-end">
318 {piece}
319 </Box>
320 ) : (
321 piece
322 )
323 }
324 // Unmarked paragraphs in a row stay one group, so a list keeps its numbering.
325 const groups: { text: string; isMarked: boolean }[] = []
326 parts.forEach((part, i) => {
327 const last = groups[groups.length - 1]
328 const isMarked = flags[i] === true
329 if (!isMarked && last && !last.isMarked) last.text += `\n\n${part}`
330 else groups.push({ text: part, isMarked })
331 })
332 // Every group goes through the hooks beneath, so a mod there (a text direction, say) still shapes
333 // it. The app draws only the first reference to its own drawing in one tree (the rest of a reply
334 // vanished, 22.10.2026): the first group keeps that reference, with the bullet that opens a reply.
335 // A later group is asked for as markdown (`asMarkdown`): a mod beneath that has a say draws it that
336 // way itself, and one that comes back as the app's reference is drawn as markdown here.
337 const drawn: RenderChildren[] = []
338 for (const [i, group] of groups.entries()) {
339 const props = i === 0 ? { ...e.props, text: group.text } : { ...e.props, text: group.text, isFirstOfReply: false, asMarkdown: true }
340 const beneath = await next({ ...e, props })
341 const piece = i === 0 || wordsOf(beneath).length > 0 ? beneath : own(group.text, i)
342 drawn.push(
343 group.isMarked ? (
344 <Box key={`m${i}`} backgroundColor={HIGHLIGHT} paddingX={1}>
345 {piece}
346 </Box>
347 ) : (
348 piece
349 ),
350 )
351 }
352 return (
353 <Box flexDirection="column" gap={1}>
354 {drawn}
355 </Box>
356 )
357 })
358
359 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
360 if (e.props.hasSurvey) return next(e)
361 const beneath = await next(e)
362 const isActive = await read($, isOn)
363 const ui = $.ui.resolve(e)
364 const { Box, Button } = ui
365 const isDesktop = e.surface === 'desktop' && 'Svg' in ui
366 // The same label while the model reads, so the button keeps its width and its seat.
367 const label = isActive ? '↩ No TL;DR' : 'TL;DR'
368 const button = (
369 <Button key={isActive ? 'tldr-off' : 'tldr-on'} label={label} hotkey={isDesktop ? undefined : 'y'} onPress={() => toggle($)} />
370 )
371
372 // The shared row, or Smart PreCompact's; with neither, this mod draws the shared row.
373 const divider = isDesktop && 'Svg' in ui ? <ui.Svg source={DIVIDER} alt="" width={9} height={16} /> : null
374 const placed = seat(beneath, [button], { px: buttonPx(label), columns: e.props.bodyColumns, isDesktop, divider })
375 if ('tree' in placed) return placed.tree
376 return (
377 <Box flexDirection="column">
378 <Box key={ROW_KEY} flexDirection="row" flexWrap="wrap" alignItems="center" gap={1}>
379 <Box flexGrow={1} />
380 {placed.carry}
381 {button}
382 </Box>
383 {placed.beneath}
384 </Box>
385 )
386 })
387}
388hooks/panel.ts 131 lines1// One row above the prompt that mods can share (TL;DR, and Smart PreCompact's own row beneath it).
2// Shared file: every mod that draws this row carries the same copy.
3//
4// Hooks on the row above the prompt nest: each one gets the drawing of the hooks beneath it from
5// next(e), as plain data ({ type, props, children }), and returns the whole drawing. Which mod is
6// outer depends on the order the skills folder lists them, so this works in any order:
7// - on the desktop, Smart PreCompact's row (key 'spc-band' or 'spc-receipt') takes the buttons when
8// it has room, and a shared row an inner mod drew before it is folded into it;
9// - otherwise the shared row (key 'mods-row') takes them, at its end, along with any of the
10// collection's buttons an inner mod had put in Smart PreCompact's row, so they never split;
11// - with no row to join, `seat` hands back what the mod's own shared row must also hold.
12
13import type { RenderChildren } from 'claude-code'
14
15type Node = { type?: unknown; props?: Record<string, unknown>; children?: unknown[] }
16
17export const ROW_KEY = 'mods-row'
18const HOST_KEYS = ['spc-band', 'spc-receipt']
19
20// CSS pixels per band column on the desktop, and a native button's width: about 7.2px a character,
21// 12px of padding and the one-column gap before it (measured in the Code tab, 6.10.26).
22export const COLUMN_PX = 7.9
23export const buttonPx = (label: string) => label.length * 7.2 + 12 + COLUMN_PX
24
25// The hairline between this collection's buttons and Smart PreCompact's own; told apart by its empty alt.
26export const DIVIDER = '<svg xmlns="http://www.w3.org/2000/svg" width="9" height="16" viewBox="0 0 9 16"><rect x="4" y="1" width="1" height="14" rx=".5" fill="rgba(60,55,45,.18)"/></svg>'
27export const DIVIDER_PX = 9 + COLUMN_PX
28
29const keyOf = (node: unknown) => {
30 const key = (node as Node | null)?.props?.key
31 return typeof key === 'string' ? key : null
32}
33const isButton = (node: unknown) => (node as Node | null)?.type === 'Button'
34const isSvg = (node: unknown) => (node as Node | null)?.type === 'Svg'
35const isDivider = (node: unknown) => isSvg(node) && (node as Node).props?.alt === ''
36
37/** The first node, depth first, that `pick` chooses. */
38const find = (node: unknown, pick: (n: Node) => boolean): Node | null => {
39 if (node === null || typeof node !== 'object') return null
40 const n = node as Node
41 if (pick(n)) return n
42 for (const child of n.children ?? []) {
43 const found = find(child, pick)
44 if (found) return found
45 }
46 return null
47}
48
49/** The tree with `target` replaced by `swap(target)` (or dropped when it returns null), copied along the path. */
50const replace = (node: unknown, target: Node, swap: (n: Node) => unknown): unknown => {
51 if (node === target) return swap(target)
52 if (node === null || typeof node !== 'object') return node
53 const n = node as Node
54 if (!n.children) return node
55 let changed = false
56 const children: unknown[] = []
57 for (const child of n.children) {
58 const next = replace(child, target, swap)
59 if (next !== child) changed = true
60 if (next !== null) children.push(next)
61 }
62 return changed ? { ...n, children } : node
63}
64
65/** What a row's images and buttons take on the desktop, in CSS pixels. */
66export const rowPx = (nodes: readonly unknown[]) =>
67 nodes.reduce<number>((sum, child) => {
68 const c = child as Node | null
69 if (isSvg(c) && typeof c?.props?.width === 'number') return sum + c.props.width + COLUMN_PX
70 if (isButton(c) && typeof c?.props?.label === 'string') return sum + buttonPx(c.props.label)
71 return sum
72 }, 0)
73
74export type SeatOptions = {
75 /** What `items` take on the desktop, in CSS pixels. */
76 px: number
77 /** The band's width in columns (e.props.bodyColumns). */
78 columns: number
79 /** Smart PreCompact's row is joined on the desktop only: a terminal row has no room to spare. */
80 isDesktop: boolean
81 /** The hairline, drawn by the caller's own Svg; null where there is none. */
82 divider: unknown
83}
84
85/**
86 * Seated: `tree`, the drawing beneath with `items` in the shared row or in Smart PreCompact's.
87 * Not seated: the mod draws the shared row (key ROW_KEY) above `beneath`, holding `carry` (buttons
88 * of the collection taken out of Smart PreCompact's row) before its own items.
89 */
90export type Seat<T> = { tree: T } | { beneath: T; carry: RenderChildren[] }
91
92/** The collection's images and buttons in Smart PreCompact's row: after its spacer, up to the hairline. */
93const guestsOf = (kids: readonly unknown[]) => {
94 const divider = kids.findIndex(isDivider)
95 if (divider === -1) return { from: -1, to: -1 }
96 const spacer = kids.findIndex(c => (c as Node | null)?.type === 'Box' && (c as Node).props?.flexGrow !== undefined)
97 return { from: spacer !== -1 && spacer < divider ? spacer + 1 : 0, to: divider }
98}
99
100export const seat = <T>(beneath: T, items: readonly unknown[], o: SeatOptions): Seat<T> => {
101 const row = find(beneath, n => keyOf(n) === ROW_KEY)
102 const host = o.isDesktop ? find(beneath, n => HOST_KEYS.includes(keyOf(n) ?? '')) : null
103 let tree: unknown = beneath
104 let carry: unknown[] = []
105 if (host) {
106 const kids = host.children ?? []
107 // A shared row an inner mod drew under Smart PreCompact's comes along: its images and buttons.
108 const moving = row ? (row.children ?? []).filter(c => isButton(c) || isSvg(c)) : []
109 const hasDivider = kids.some(isDivider)
110 const need = o.px + rowPx(moving) + (hasDivider || o.divider === null ? 0 : DIVIDER_PX)
111 const columns = Number.isFinite(o.columns) && o.columns > 0 ? o.columns : 100
112 if (rowPx(kids) + need <= columns * COLUMN_PX - 16) {
113 const without = row ? replace(beneath, row, () => null) : beneath
114 // After the buttons already seated there, before Smart PreCompact's own.
115 const divider = kids.findIndex(isDivider)
116 const firstButton = kids.findIndex(isButton)
117 const at = divider !== -1 ? divider : firstButton !== -1 ? firstButton : kids.length
118 const group = [...moving, ...items, ...(hasDivider || o.divider === null ? [] : [o.divider])]
119 return { tree: replace(without, host, h => ({ ...h, children: [...kids.slice(0, at), ...group, ...kids.slice(at)] })) as T }
120 }
121 // No room for all of them: the ones already there come out, with the hairline, to the shared row.
122 const { from, to } = guestsOf(kids)
123 if (from !== -1) {
124 carry = kids.slice(from, to)
125 tree = replace(beneath, host, h => ({ ...h, children: [...kids.slice(0, from), ...kids.slice(to + 1)] }))
126 }
127 }
128 if (row) return { tree: replace(tree, row, r => ({ ...r, children: [...(r.children ?? []), ...carry, ...items] })) as T }
129 return { beneath: tree as T, carry: carry as RenderChildren[] }
130}
131types/index.d.ts 22 lines1/** The last long reply, as the mod read it. */
2export type Brief = { tldr: string; marked: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 tldr: {
7 /** Highlight long replies. Kept across sessions in $.store. */
8 isOn: boolean
9 /** The model is reading a reply. */
10 isBusy: boolean
11 /** The TL;DR of the last long reply, or null when the last reply was short. */
12 brief: Brief | null
13 /** The id of the last reply read, so one reply is never sent to the model twice. */
14 lastRead: string | null
15 /** A reply arrived while one was being read: read again when done. */
16 again: boolean
17 /** One flag per paragraph, by its id (keyOf): true when it is highlighted. */
18 marked: StateFamily<boolean>
19 }
20 }
21}
22