/huh opens Claude's last answer in the sidebar, rewritten to be easy to read (a short summary first, clear sections, lists), with the original one click away.

/huh opens Claude's last answer in the sidebar, made easy to read. The answer shows at once, formatted for the terminal; meanwhile Claude (Haiku, low effort) rewrites it for reading, and the sidebar switches to that:

Nothing is spent until you ask: the rewrite runs only when you call /huh. A long answer is rewritten in parts at the same time, with the summary beside them, so it takes about as long as a short one.
Show original switches back to the answer as Claude wrote it. Nothing is saved anywhere and nothing else opens: it all stays in the sidebar, in the terminal and in the desktop app.
On the light and dark pages, where the terminal shows pictures (WezTerm, kitty, Ghostty, iTerm2, herdr with pictures on), the answer is typeset like a web page: a real proportional font at about the terminal's size, 1.65 line spacing, larger headings with rules, the "In short" in a tinted panel, clean tables and code. A headless browser (Chrome, Chromium, Edge or Brave) draws it; it shows in the sidebar as picture strips, cut between lines, and the pane scrolls through them. Only a cache is written (~/Library/Caches/huh); nothing opens. It follows the sidebar's width and redraws when you switch page or version.
On terminal colors, in the desktop app, and wherever pictures do not show, the answer is drawn as text: Claude Code's own Markdown renderer on terminal colors, the page's own colors on light and dark.
The page is drawn at exactly the sidebar's size in screen pixels and cut into strips a whole number of rows tall, so the terminal shows it 1:1 and it stays sharp. That needs a terminal cell's size in pixels, which a mod cannot read: the cellSize option (default 17x37, JetBrains Mono 14 pt on a Retina screen) sets it. If the page looks soft or stretched, measure one cell (a screenshot at full size, a column's width and a line's height) and set it.
Switch with the page button, or /huh light, /huh dark, /huh terminal; the choice is kept. The page design is scripts/page.css.
hooks/register.tsx 394 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Reading, Typeset, Version } from '../types'
5import { blocks, partsOf, sections, titleOf } from './markdown'
6import type { Span } from './markdown'
7import { spans as spansOf } from './markdown'
8
9const PANE = 'huh'
10const MODEL = 'haiku'
11/** Answers longer than this are rewritten in parts at once, so a long one takes about as long as a short one. */
12const PART_CHARS = 2400
13const PAGE_KEY = 'page'
14
15/** The page the reader is set on: its own light or dark page, or the terminal's colors. */
16type Page = 'light' | 'dark' | 'terminal'
17const PAGES: Page[] = ['light', 'dark', 'terminal']
18
19/** Each page's colors, picked for contrast on it: body text, headings, code, the dim lines, the panel's edge. */
20const PALETTES: Record<Page, { background?: string; text?: string; accent: string; code: string; block: string; muted: string; edge: string }> = {
21 light: { background: '#f6f6f3', text: '#24292f', accent: '#1f5fa8', code: '#8250df', block: '#116329', muted: '#6e7781', edge: '#c8ccd2' },
22 dark: { background: '#17191e', text: '#e3e5e8', accent: '#7aa7e8', code: '#c9a2ff', block: '#9ad48a', muted: '#8b929b', edge: '#3a3f47' },
23 terminal: { accent: '#6f9bd8', code: '#c792ea', block: '#c3e88d', muted: 'inactive', edge: 'inactive' },
24}
25
26const reading = atom({ plugin: 'huh', key: 'reading' } as const, null as Reading | null)
27const version = atom({ plugin: 'huh', key: 'version' } as const, 'readable' as Version)
28const page = atom({ plugin: 'huh', key: 'page' } as const, 'terminal' as Page)
29const typeset = atom({ plugin: 'huh', key: 'typeset' } as const, null as Typeset | null)
30
31/**
32 * A terminal cell's size in screen pixels: the page is drawn at exactly that many pixels per column and cut
33 * into strips a whole number of rows tall, so the terminal shows it 1:1. 17×37 is JetBrains Mono at 14 pt on a
34 * Retina screen; the cellSize option changes it.
35 */
36let cell = { width: 17, height: 37 }
37/** The sidebar's width as last drawn, in columns; the page is typeset to it. */
38let columns = 64
39let typesetting: string | null = null
40
41/** Readable versions already made, by the answer they came from, so reopening one is instant. */
42const made = new Map<string, string>()
43/** Rewrites under way, by the answer, so a second /huh joins the first instead of asking again. */
44const pending = new Map<string, Promise<Rewrite>>()
45
46type Rewrite = { text: string } | { problem: string }
47
48const REWRITE = `You rewrite an answer from a coding assistant so it is easy to read in a narrow sidebar (about 60 characters wide).
49
50Keep every fact, decision, number, file path, command, code block and caveat. Do not add anything, do not drop anything that matters, and do not change what it says. Write in the answer's own language.
51
52Shape it for reading:
53- Start with "**In short:**" and one to three plain sentences: what happened and what the reader needs to do, if anything.
54- Then short sections, each under a "## " heading of a few words.
55- Short paragraphs; one idea each. Sentences of twenty words or fewer, in plain words.
56- Lists for steps and for sets of parallel items. Start each item with its key word or phrase in **bold**, so the
57 list can be scanned down its left edge.
58- Tables only when they have two or three columns; otherwise turn them into lists.
59- Code, commands and paths stay in backticks or code blocks, unchanged.
60- End with a "## Next" section only if the answer asks the reader to do something.
61
62Reply with the rewritten Markdown only: no preamble, no closing remarks.`
63
64function scriptOf($: EngineInterface): string {
65 return `${$.plugin.root.replace(/\/\.claude-plugin\/?$/, '')}/scripts/typeset.py`
66}
67
68/** Whether this terminal shows pictures (kitty graphics): the typeset page, or the text view. */
69async function showsPictures($: EngineInterface): Promise<boolean> {
70 if ((await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES')) === '1') return true
71 if (await $.env.get('KITTY_WINDOW_ID')) return true
72 const program = (await $.env.get('TERM_PROGRAM')) ?? ''
73 return ['WezTerm', 'ghostty', 'kitty', 'iTerm.app'].includes(program)
74}
75
76/** What the typeset page shows now: the version on screen, on the chosen page, at the sidebar's width. */
77async function wanted($: EngineInterface): Promise<{ key: string; markdown: string; title: string; page: 'light' | 'dark' } | null> {
78 const current = await read($, reading)
79 const setting = await read($, page)
80 if (current === null || setting === 'terminal') return null
81 const shown = await read($, version)
82 const markdown = shown === 'readable' && current.readable ? current.readable : current.original
83 return { key: `${setting}:${columns}:${cell.width}x${cell.height}:${markdown.length}:${markdown.slice(0, 64)}:${markdown.slice(-64)}`, markdown, title: current.title, page: setting }
84}
85
86/**
87 * Typesets the page as a browser would draw it (real fonts, line spacing, headings) into picture strips for
88 * the sidebar. Only a cache is written; nothing opens.
89 */
90async function typesetPage($: EngineInterface) {
91 const want = await wanted($)
92 if (want === null || !(await showsPictures($))) return
93 if ((await read($, typeset))?.key === want.key || typesetting === want.key) return
94 typesetting = want.key
95 try {
96 const stdin = JSON.stringify({ markdown: want.markdown, title: want.title, page: want.page, columns, cell: [cell.width, cell.height] })
97 const ran = await $.process.run(['python3', scriptOf($)], { stdin, timeoutMs: 120_000 })
98 if (ran.exitCode !== 0) return
99 const done = JSON.parse(ran.stdout) as { strips: string[]; heights: number[]; width: number; problem: string | null }
100 if (done.problem || done.strips.length === 0) return
101 if ((await wanted($))?.key !== want.key) return
102 await update($, typeset, () => ({ key: want.key, strips: done.strips, heights: done.heights, width: done.width }))
103 } finally {
104 if (typesetting === want.key) typesetting = null
105 }
106}
107
108/** Claude's last answer: the text of the latest assistant message that has any. */
109async function lastAnswer($: EngineInterface): Promise<string | null> {
110 const messages = await $.session.messages({ as: 'api' })
111 for (let index = messages.length - 1; index >= 0; index--) {
112 const message = messages[index]!
113 if (message.role !== 'assistant') continue
114 const content = typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content
115 const text = (content as { type: string; text?: string }[])
116 .filter(block => block.type === 'text' && block.text)
117 .map(block => block.text!)
118 .join('\n\n')
119 .trim()
120 if (text !== '') return text
121 }
122 return null
123}
124
125const PART_RULES = `This is one part of a longer answer; the other parts are rewritten separately and joined after it.
126Do not start with "**In short:**" and do not add a "## Next" section unless this part asks the reader to do something.`
127
128const SHORT = `Summarize this answer from a coding assistant for a reader in a narrow sidebar: "**In short:**" then one to three
129plain sentences of twenty words or fewer: what happened and what the reader needs to do, if anything. Write in the
130answer's own language. Reply with that line only.`
131
132async function ask($: EngineInterface, system: string, prompt: string, maxTokens: number): Promise<Rewrite> {
133 const result = await $.model.complete({ model: MODEL, system, prompt, maxTokens, effort: 'low', timeoutMs: 120_000 })
134 if (result.isAnswered && result.text.trim() !== '') return { text: result.text.trim() }
135 return { problem: result.isAnswered ? 'the reply was empty' : result.reason === 'api-error' ? `the API answered ${result.status ?? 'with an error'}` : result.reason }
136}
137
138/** The readable version: one request for a short answer; for a long one, every part and the summary at once. */
139function rewrite($: EngineInterface, original: string): Promise<Rewrite> {
140 const known = made.get(original)
141 if (known) return Promise.resolve({ text: known })
142 const running = pending.get(original)
143 if (running) return running
144 const parts = partsOf(original, PART_CHARS)
145 const work = (async (): Promise<Rewrite> => {
146 const done =
147 parts.length < 2
148 ? [await ask($, REWRITE, original, 8000)]
149 : await Promise.all([
150 ask($, SHORT, original, 400),
151 ...parts.map((part, index) => ask($, `${REWRITE}\n\n${PART_RULES}${index === 0 ? '' : '\nDo not repeat a title.'}`, part, 8000)),
152 ])
153 const failed = done.find((one): one is { problem: string } => 'problem' in one)
154 if (failed) return failed
155 const text = done.map(one => (one as { text: string }).text).join('\n\n')
156 made.set(original, text)
157 return { text }
158 })().finally(() => pending.delete(original))
159 pending.set(original, work)
160 return work
161}
162
163/** Shows the readable version once it arrives; the original stays on screen until then. */
164async function makeReadable($: EngineInterface, original: string) {
165 const result = await rewrite($, original)
166 const current = await read($, reading)
167 if (current === null || current.original !== original) return
168 if ('text' in result) {
169 await update($, reading, value => (value ? { ...value, readable: result.text, state: 'ready', problem: null } : value))
170 await update($, version, value => (value === 'original' ? 'readable' : value))
171 void typesetPage($).catch(() => undefined)
172 } else {
173 await update($, reading, value => (value ? { ...value, state: 'failed', problem: result.problem } : value))
174 await update($, version, () => 'original')
175 }
176}
177
178export const register: Register = (on, options) => {
179 const asked = /^(\d+)\s*[x×]\s*(\d+)$/.exec(String((options as { cellSize?: string }).cellSize ?? '').trim())
180 if (asked) cell = { width: Number(asked[1]), height: Number(asked[2]) }
181
182 on('session.start', async ($, e, next) => {
183 // Not awaited: registering makes Claude Code re-describe every command, which would hold startup.
184 // Typed as /huh in the terminal; the command file is what the desktop app lists in its menu.
185 void $.command.register({ name: 'huh', description: "Claude's last answer, made easy to read, in the sidebar", argumentHint: '[light|dark|terminal]' }).catch(() => undefined)
186 const stored = await $.store.get(PAGE_KEY)
187 if (stored === 'light' || stored === 'dark' || stored === 'terminal') await update($, page, () => stored)
188 return next(e)
189 })
190
191 on('command.run', { command: ['huh', 'huh:huh'] }, async ($, e) => {
192 const asked = e.args.trim()
193 if (asked === 'light' || asked === 'dark' || asked === 'terminal') {
194 await update($, page, () => asked)
195 await $.store.set(PAGE_KEY, asked)
196 void typesetPage($).catch(() => undefined)
197 if (await read($, reading)) return { text: `Reader page: ${asked}.` }
198 }
199
200 const original = await lastAnswer($)
201 if (original === null) return { text: 'There is no answer from Claude in this conversation yet.' }
202 const known = made.get(original)
203 await update($, reading, () => ({
204 title: titleOf(original),
205 original,
206 readable: known ?? null,
207 state: known ? 'ready' : 'rewriting',
208 problem: null,
209 }))
210 await update($, version, () => (known ? 'readable' : 'original'))
211 // Focused, so it comes to the front where a surface shows one pane at a time (the desktop app).
212 const opened = await $.ui.open({ id: PANE, title: 'Reader', focus: true })
213 void typesetPage($).catch(() => undefined)
214 if (!known) void makeReadable($, original)
215 return { text: opened.isPlaced ? 'Opened in the sidebar.' : 'The sidebar could not open here.' }
216 }).catch(($, e, next) => (next.called ? undefined : next(e)) as never)
217
218 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
219 const { Box, Text, Button, Markdown, Image } = $.ui.resolve(e)
220 const current = await read($, reading)
221 const setting = await read($, page)
222 const colors = PALETTES[setting]
223 const bg = colors.background
224 if (current === null) return <Text color={colors.muted} wrap="wrap">Run /huh to open Claude's last answer here, made easy to read.</Text>
225 const shown = await read($, version)
226 const markdown = shown === 'readable' && current.readable ? current.readable : current.original
227 if (Math.abs(e.props.bodyColumns - columns) >= 6) {
228 columns = e.props.bodyColumns
229 void typesetPage($).catch(() => undefined)
230 }
231 const margin = bg ? 1 : 0
232 const width = Math.max(20, e.props.bodyColumns - margin * 2)
233 const gap = 1
234 // The terminal's own colors read best through the engine's Markdown, as Claude's replies do; the pages
235 // keep every color their own, so their text is drawn here.
236 const native = setting === 'terminal' && Markdown !== undefined
237
238 // Every piece of text carries the page's colors, so the page reads as one sheet in any terminal.
239 const T = (props: { children: unknown; color?: string; bold?: boolean; wrap?: 'wrap'; key?: string }) => (
240 <Text key={props.key} color={props.color ?? colors.text} backgroundColor={bg} bold={props.bold} wrap={props.wrap}>
241 {props.children as never}
242 </Text>
243 )
244 const inline = (parts: Span[], color?: string) =>
245 parts.map(part => <Text color={part.code ? colors.code : color ?? colors.text} backgroundColor={bg} bold={part.bold}>{part.text}</Text>)
246 const nextPage = PAGES[(PAGES.indexOf(setting) + 1) % PAGES.length]!
247 const pageLabel = { light: 'Light page', dark: 'Dark page', terminal: 'Terminal colors' }
248
249 const toolbar = (
250 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
251 {current.state === 'ready' && (
252 <Button
253 key="toggle"
254 label={shown === 'readable' ? 'Show original' : 'Show readable'}
255 plain
256 onPress={async () => {
257 await update($, version, value => (value === 'readable' ? 'original' : 'readable'))
258 void typesetPage($).catch(() => undefined)
259 }}
260 />
261 )}
262 <Button
263 key="page"
264 label={`${pageLabel[setting]} → ${pageLabel[nextPage].toLowerCase()}`}
265 plain
266 onPress={async () => {
267 await update($, page, () => nextPage)
268 await $.store.set(PAGE_KEY, nextPage)
269 void typesetPage($).catch(() => undefined)
270 }}
271 />
272 </Box>
273 )
274 const status =
275 current.state === 'rewriting'
276 ? T({ color: colors.accent, children: 'Making it easier to read… the original is below until then.' })
277 : current.state === 'failed'
278 ? T({ color: '#d1242f', wrap: 'wrap', children: `Could not make a readable version (${current.problem}). Showing the original.` })
279 : T({ color: colors.muted, children: shown === 'readable' ? 'Rewritten for reading; same content.' : 'As Claude wrote it.' })
280 const rule = (at: string) => T({ key: at, color: colors.edge, children: '─'.repeat(Math.min(width, 32)) })
281
282 /** A heading: section headings bold in the accent with a rule under them, smaller ones bold. */
283 const heading = (text: string, level: number, at: string) => {
284 const major = level <= 2
285 return (
286 <Box key={at} flexDirection="column" marginTop={major ? 1 : 0}>
287 <Text bold color={major ? colors.accent : colors.text} backgroundColor={bg} wrap="wrap">{inline(spansOf(text), major ? colors.accent : undefined)}</Text>
288 {major && rule(`${at}-rule`)}
289 </Box>
290 )
291 }
292
293 /** A section's body: the engine's Markdown on terminal colors, else drawn block by block for the page. */
294 const body = (text: string, at: string) => {
295 if (text.trim() === '') return null
296 if (native) return <Markdown key={at} text={text} />
297 return (
298 <Box key={at} flexDirection="column" rowGap={gap}>
299 {blocks(text).map((block, index) => {
300 const key = `${at}-${index}`
301 switch (block.kind) {
302 case 'heading':
303 return heading(block.spans.map(span => span.text).join(''), block.level, key)
304 case 'paragraph':
305 return <Text key={key} backgroundColor={bg} wrap="wrap">{inline(block.spans)}</Text>
306 case 'item':
307 return (
308 <Box key={key} flexDirection="row" paddingLeft={block.depth * 2} columnGap={1}>
309 <Box flexShrink={0}>{T({ color: colors.accent, children: block.marker })}</Box>
310 <Text backgroundColor={bg} wrap="wrap">{inline(block.spans)}</Text>
311 </Box>
312 )
313 case 'code':
314 return (
315 <Box key={key} flexDirection="column" borderStyle="single" borderColor={colors.edge} paddingX={1} backgroundColor={bg}>
316 {block.lines.map((line, row) => T({ key: `${key}-${row}`, color: colors.block, wrap: 'wrap', children: line || ' ' }))}
317 </Box>
318 )
319 case 'quote':
320 return (
321 <Box key={key} flexDirection="row" columnGap={1}>
322 {T({ color: colors.edge, children: '│' })}
323 <Text backgroundColor={bg} wrap="wrap">{inline(block.spans, colors.muted)}</Text>
324 </Box>
325 )
326 case 'row':
327 return (
328 <Box key={key} flexDirection="row" columnGap={2}>
329 {block.cells.map((cell, column) => (
330 <Box key={`${key}-${column}`} flexGrow={1} flexShrink={1}>
331 <Text bold={block.isHeader} backgroundColor={bg} wrap="wrap">{inline(cell, block.isHeader ? colors.accent : undefined)}</Text>
332 </Box>
333 ))}
334 </Box>
335 )
336 default:
337 return rule(key)
338 }
339 })}
340 </Box>
341 )
342 }
343
344 // The typeset page: picture strips one under another, the pane scrolling through them.
345 const set = await read($, typeset)
346 const want = await wanted($)
347 if (e.surface === 'terminal' && Image !== undefined && set !== null && want !== null && set.key === want.key) {
348 return (
349 <Box flexDirection="column" rowGap={1}>
350 {toolbar}
351 {current.state !== 'ready' && status}
352 <Box flexDirection="column">
353 {set.strips.map((strip, index) => (
354 <Image
355 key={`strip-${index}`}
356 source={{ file: strip, format: 'png' }}
357 columns={columns}
358 rows={Math.max(1, Math.round(set.heights[index]! / cell.height))}
359 alt={index === 0 ? current.title : ' '}
360 />
361 ))}
362 </Box>
363 </Box>
364 )
365 }
366
367 const parts = sections(markdown)
368 // The buttons sit above the page, on the terminal's own colors: a button draws in the theme's colors,
369 // which would fade on a light page.
370 return (
371 <Box flexDirection="column" rowGap={1}>
372 {toolbar}
373 <Box flexDirection="column" rowGap={gap} backgroundColor={bg} paddingX={margin} paddingY={bg ? 1 : 0}>
374 <Box flexDirection="column">
375 {T({ bold: true, color: colors.accent, wrap: 'wrap', children: current.title })}
376 {status}
377 </Box>
378 {parts.summary && (
379 <Box borderStyle="round" borderColor={colors.accent} paddingX={1} backgroundColor={bg}>
380 {native ? <Markdown text={parts.summary} /> : <Text backgroundColor={bg} wrap="wrap">{inline(spansOf(parts.summary))}</Text>}
381 </Box>
382 )}
383 {parts.sections.map((section, at) => (
384 <Box key={`s${at}`} flexDirection="column" rowGap={gap}>
385 {section.heading && heading(section.heading.text, section.heading.level, `h${at}`)}
386 {body(section.body, `s${at}-body`)}
387 </Box>
388 ))}
389 </Box>
390 </Box>
391 )
392 })
393}
394hooks/markdown.ts 145 lines1/**
2 * Markdown read into blocks the sidebar's reading view draws: headings, paragraphs, list items, code, quotes,
3 * table rows, rules. Inline text becomes spans (plain, bold, code), so emphasis survives without the markers.
4 */
5
6export type Span = { text: string; bold?: boolean; code?: boolean }
7
8export type Block =
9 | { kind: 'heading'; level: number; spans: Span[] }
10 | { kind: 'paragraph'; spans: Span[] }
11 | { kind: 'item'; depth: number; marker: string; spans: Span[] }
12 | { kind: 'code'; lines: string[] }
13 | { kind: 'quote'; spans: Span[] }
14 | { kind: 'row'; cells: Span[][]; isHeader: boolean }
15 | { kind: 'rule' }
16
17export function spans(text: string): Span[] {
18 const out: Span[] = []
19 for (const part of text.split(/(`[^`]+`)/)) {
20 if (part.startsWith('`') && part.endsWith('`') && part.length > 1) {
21 out.push({ text: part.slice(1, -1), code: true })
22 continue
23 }
24 for (const piece of part.split(/(\*\*[^*]+\*\*)/)) {
25 if (piece === '') continue
26 const plain = piece.replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1').replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?!\w)/g, '$1$2')
27 if (piece.startsWith('**') && piece.endsWith('**') && piece.length > 4) out.push({ text: piece.slice(2, -2), bold: true })
28 else out.push({ text: plain })
29 }
30 }
31 return out
32}
33
34export function blocks(markdown: string): Block[] {
35 const lines = markdown.replace(/\r\n/g, '\n').split('\n')
36 const out: Block[] = []
37 for (let index = 0; index < lines.length; index++) {
38 const line = lines[index]!
39 const fence = /^\s*(```|~~~)/.exec(line)
40 if (fence) {
41 const body: string[] = []
42 for (index++; index < lines.length && !lines[index]!.trim().startsWith(fence[1]!); index++) body.push(lines[index]!)
43 out.push({ kind: 'code', lines: body })
44 continue
45 }
46 const heading = /^(#{1,6})\s+(.*)/.exec(line)
47 if (heading) {
48 out.push({ kind: 'heading', level: heading[1]!.length, spans: spans(heading[2]!.trim()) })
49 continue
50 }
51 if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
52 out.push({ kind: 'rule' })
53 continue
54 }
55 if (line.trim().startsWith('|')) {
56 if (/^\s*\|?\s*:?-{2,}/.test(line)) continue
57 const cells = line.trim().replace(/^\||\|$/g, '').split('|').map(cell => spans(cell.trim()))
58 const next = lines[index + 1] ?? ''
59 out.push({ kind: 'row', cells, isHeader: /^\s*\|?\s*:?-{2,}/.test(next) })
60 continue
61 }
62 if (line.startsWith('>')) {
63 out.push({ kind: 'quote', spans: spans(line.replace(/^>\s?/, '')) })
64 continue
65 }
66 const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)/.exec(line)
67 if (item) {
68 const marker = /\d/.test(item[2]![0]!) ? item[2]!.replace(')', '.') : '•'
69 out.push({ kind: 'item', depth: Math.floor(item[1]!.replace(/\t/g, ' ').length / 2), marker, spans: spans(item[3]!) })
70 continue
71 }
72 if (line.trim() === '') continue
73 // A paragraph runs to the next blank line or block.
74 const paragraph = [line.trim()]
75 while (index + 1 < lines.length && lines[index + 1]!.trim() !== '' && !/^(#{1,6}\s|\s*```|\s*~~~|>|\s*([-*+]|\d+[.)])\s|\s*\|)/.test(lines[index + 1]!)) {
76 paragraph.push(lines[++index]!.trim())
77 }
78 out.push({ kind: 'paragraph', spans: spans(paragraph.join(' ')) })
79 }
80 return out
81}
82
83/** The answer's title: its first heading, else its first sentence, short. */
84export function titleOf(markdown: string): string {
85 const heading = /^#{1,3}\s+(.+)$/m.exec(markdown)
86 const plain = (heading ? heading[1]! : markdown.split('\n').find(line => line.trim() !== '') ?? 'Claude’s answer')
87 .replace(/[`*_#>[\]]/g, '')
88 .trim()
89 const sentence = plain.split(/(?<=[.!?:])\s/)[0]!
90 return sentence.length > 70 ? `${sentence.slice(0, 67).trimEnd()}…` : sentence
91}
92
93/**
94 * The answer cut at its headings: the "In short" summary when it opens the answer, then each heading with the
95 * text under it. Headings inside code blocks stay in the code.
96 */
97export function sections(markdown: string): { summary: string | null; sections: { heading: { level: number; text: string } | null; body: string }[] } {
98 const lines = markdown.replace(/\r\n/g, '\n').split('\n')
99 const out: { heading: { level: number; text: string } | null; body: string[] }[] = [{ heading: null, body: [] }]
100 let inCode = false
101 for (const line of lines) {
102 if (/^\s*(```|~~~)/.test(line)) inCode = !inCode
103 const heading = inCode ? null : /^(#{1,3})\s+(.*)/.exec(line)
104 if (heading) out.push({ heading: { level: heading[1]!.length, text: heading[2]!.trim() }, body: [] })
105 else out[out.length - 1]!.body.push(line)
106 }
107 // "**In short:** ..." as the opening paragraph becomes the summary.
108 let summary: string | null = null
109 const first = out[0]!
110 const opening = first.body.join('\n').trim()
111 const found = /^\*\*In short:?\*\*:?\s*([\s\S]*?)(\n\s*\n|$)/i.exec(opening)
112 if (found) {
113 summary = `**In short:** ${found[1]!.trim()}`
114 first.body = opening.slice(found[0].length).split('\n')
115 }
116 return {
117 summary,
118 sections: out
119 .map(section => ({ heading: section.heading, body: section.body.join('\n').trim() }))
120 .filter(section => section.heading !== null || section.body !== ''),
121 }
122}
123
124/**
125 * The answer cut into parts of about `limit` characters at its headings, or at blank lines when a section is longer,
126 * never inside a code block.
127 */
128export function partsOf(markdown: string, limit: number): string[] {
129 const pieces: string[] = []
130 let current: string[] = []
131 let inCode = false
132 for (const line of markdown.replace(/\r\n/g, '\n').split('\n')) {
133 const size = current.join('\n').length
134 const breaks = !inCode && current.length > 0 && ((/^#{1,3}\s/.test(line) && size > limit / 2) || (line.trim() === '' && size > limit))
135 if (breaks) {
136 pieces.push(current.join('\n').trim())
137 current = []
138 }
139 if (/^\s*(```|~~~)/.test(line)) inCode = !inCode
140 current.push(line)
141 }
142 pieces.push(current.join('\n').trim())
143 return pieces.filter(piece => piece !== '')
144}
145types/index.d.ts 28 lines1/** The answer in the reader: as Claude wrote it, and rewritten for reading once that arrives. */
2export type Reading = {
3 title: string
4 original: string
5 readable: string | null
6 state: 'rewriting' | 'ready' | 'failed'
7 /** Why there is no readable version, when that failed. */
8 problem: string | null
9}
10
11/** The answer typeset as a page, in picture strips: which text and page they show, and their sizes. */
12export type Typeset = { key: string; strips: string[]; heights: number[]; width: number }
13
14/** Which version the sidebar shows. */
15export type Version = 'readable' | 'original'
16
17declare module 'claude-code' {
18 interface PluginState {
19 huh: {
20 reading: Reading | null
21 version: Version
22 /** Light page, dark page, or the terminal's own colors. */
23 page: 'light' | 'dark' | 'terminal'
24 typeset: Typeset | null
25 }
26 }
27}
28