SLOPSHOPPER

tldr

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

bandrowstoastmodeltimer
v1.0.0no licenseupdated 2026-10-25JayGPTPro/context-mods/skills/tldr-highlight
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 [ ↩ No TL;DR ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ↩ No TL;DR ] ⟨Claude Code's own drawing⟩
README

Context Mods

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.

ModWhat you getCosts
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 PreCompactA 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.

Install

The easy way: Claude installs it

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.

By hand

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.

What TL;DR does

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.

Turning it off, or removing it

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.

When something does not show

  • No button in a new conversation: your Claude Code version does not run mods yet. Update Claude Code.
  • Two rows above the prompt instead of one: TL;DR draws into Smart PreCompact's row when it loads after it. Claude Code loads skill folders in the order your disk lists them, so on some machines Smart PreCompact comes first and keeps its own row. Everything works the same.
  • A narrow window keeps Smart PreCompact's row whole and puts TL;DR in a row of its own.
  • In a terminal Smart PreCompact always keeps its own row, and TL;DR gets a second one.
  • claude --debug writes the engine's notes about each mod to the debug log, on lines that start with the mod's name.

For developers

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.

Source 3 files
hooks/register.tsx 388 lines
1// 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}
388
hooks/panel.ts 131 lines
1// 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}
131
types/index.d.ts 22 lines
1/** 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