SLOPSHOPPER

huh

/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.

newpanecommandmodelprocess
★ 3v0.1.0MITupdated 2026-10-15omeerdvrn/o-mods/mods/huh
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · huh
│ ┃ huh ✕ › fix the failing auth test and add an audit log call │ ┃ Run /huh to open Claude's last answer here, │ ┃ made easy to read. ⏺ 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 │ │ › /huh │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · huh
Run /huh to open Claude's last answer here, made easy to read.
README

huh

/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:

  • In short: one to three sentences up top, in a panel: what happened, and what you need to do.
  • Short sections under clear headings, short paragraphs, lists for steps.
  • Every fact, path, command and code block kept as it was; nothing added.

Claude's long answer is hard to read. /huh opens it in a sidebar with a short summary and clear headings, as a light page.

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.

How it is drawn

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.

Source 3 files
hooks/register.tsx 394 lines
1import { 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}
394
hooks/markdown.ts 145 lines
1/**
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}
145
types/index.d.ts 28 lines
1/** 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