SLOPSHOPPER

speedread

RSVP speed reader for long prose: one word at a time at a fixed focal point

newpanecommandstatusprompt
v0.1.0MITupdated 2026-10-09jimmysteinmetz/b-sides/plugins/speedread
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · speedread
│ ┃ speedread · Claude’s last reply ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./reader.tsx │ ⏺ 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 │ │ › /speedread │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · speedread · Claude’s last reply
▣ client module ./reader.tsx
README

speedread

A speed reader inside Claude Code. Run /speedread to play Claude's last reply, a markdown file, or pasted text one word at a time in a side pane.

It uses RSVP (rapid serial visual presentation). Each word appears at the same spot on screen with one letter highlighted, so your eyes stay in one place. Tables, code, diagrams, and lists pause the reader and show in full until you press Enter.

Install

Run this at the Claude Code prompt in a terminal:

/plugin install speedread --marketplace jimmysteinmetz/b-sides

Press y to add the marketplace, then pick a scope. User scope turns it on in every session. Claude Code confirms with Installed speedread. Plugin is now active.

The install command only runs in the terminal. If you install at user scope, speedread also works in the Code tab of the Claude desktop app.

Use

CommandReads
/speedreadClaude's last reply
/speedread notes.mdA .md, .markdown, or .txt file. ~/ paths work.
/speedread pasteThe next prompt you send. It opens in the reader and is not sent to Claude. Run /speedread paste again to cancel.

Click the pane to start.

KeyDoes
SpacePause or play. While paused, the surrounding paragraph shows so you can re-read without losing your place.
←Back. Restarts the current sentence if you are more than 1.5 s into it, otherwise goes to the previous one.
↑ ↓Speed up or slow down by 25 wpm (100 to 1000). On a table or code block, scrolls it instead.
EnterContinue past a table, code block, or list. Accept the resume prompt. Close when finished.
rStart over instead of resuming.
q / EscClose the reader.

The bar along the bottom shows percent read, estimated time left, and current speed.

What it does with your text

  • Pacing. Each word shows for 60000 / wpm ms. Long words, numbers, and acronyms stay up longer. Words that end a clause, sentence, or paragraph pause longer still.
  • Headings show on their own for 1.5 s.
  • Blocks (tables, code, Mermaid diagrams, lists, block quotes, $$ math, images) render as markdown and wait for Enter. Lists can play word by word instead; see Settings.
  • Skipped: YAML front matter, horizontal rules, and lines of raw HTML.
  • After a pause or a block, a dot blinks at the focal point for a second so your eye is in the right place when words resume.

Settings

SettingDefaultDoes
Play lists word by wordOffLists normally show whole until you press Enter. Turn this on to play list items one word at a time, like prose.

Change it in /config, or when you install.

What it remembers

  • Your reading speed, across sessions.
  • Your place in each file, so reopening it offers to resume. Editing a file resets its saved place. speedread remembers up to 50 files and forgets a file once you finish it.

Replies and pasted text are not saved.

Privacy

speedread makes no network calls and never calls a model. It reads only the file you name or the last reply in your session, and it saves only your speed and file positions, in Claude Code's local plugin storage. Run claude plugin validate plugins/speedread in this repo to see every Claude Code API it calls.

Requirements

  • Claude Code. Tested on 2.1.295; older versions may lack the plugin features speedread uses.
  • The terminal or the Claude desktop app. Other surfaces show a message instead of the reader.

Limitations

  • Reads .md, .markdown, and .txt only. No PDFs.
  • One document at a time. Running /speedread again replaces what is open.

Update and uninstall

claude plugin marketplace update b-sides
claude plugin update speedread@b-sides

Restart Claude Code after updating.

claude plugin uninstall speedread@b-sides

How it works

speedread is a Claude Code plugin made of a single hooks module. There is no build step: Claude Code loads the TypeScript directly.

flowchart LR
  cmd["/speedread"] --> reg["register.tsx<br/>command, paste, pane,<br/>saved positions"]
  reg --> parse["parse.ts<br/>markdown to words,<br/>headings, blocks, pages"]
  reg -- "one page" --> rdr["reader.tsx<br/>draws the pane,<br/>handles keys and clicks"]
  rdr -- "position, speed,<br/>next page" --> reg
  rdr --> core["reader-core.ts<br/>pure state machine"]
  core --> timing["timing.ts<br/>speed, focal letter,<br/>per-word delay"]
FileRole
hooks/register.tsxRegisters /speedread, intercepts the prompt in paste mode, opens the pane, and saves speed and position.
hooks/parse.tsTurns markdown into words, headings, and blocks, and splits long documents into pages under Claude Code's 100,000-character limit on pane data.
hooks/reader.tsxThe pane. Runs a 20 ms tick, takes keys and clicks, and draws the current state.
hooks/reader-core.tsEvery reader transition (play, pause, back, page turns, resume) as pure functions. Most tests live here.
hooks/timing.tsSpeed limits, per-word delay, and the focal letter's position.

Development

git clone https://github.com/jimmysteinmetz/b-sides
cd b-sides
claude --plugin-dir plugins/speedread      # run Claude Code with your working copy loaded
claude plugin validate plugins/speedread   # check the manifest and the hooks module
claude plugin test plugins/speedread       # run the tests

The first --plugin-dir session writes Claude Code's type definitions to plugins/speedread/.claude-plugin/types/ (git-ignored). After that, npx -p typescript tsc -p plugins/speedread type-checks the plugin.

Issues and pull requests are welcome. Please run validate and test before opening a PR.

Credits

The focal letter positions follow OpenSpritz.

License

MIT

Source 5 files
hooks/register.tsx 236 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Cost, Para } from './parse'
4import { costOf, flatten, paginate, parse } from './parse'
5import type { ReaderProps, Snapshot } from './reader-core'
6import { DEFAULT_WPM, clampWpm } from './timing'
7
8const PANE = 'speedread'
9const WPM_KEY = 'wpm'
10const RESUME_PREFIX = 'pos:'
11const MAX_RESUMES = 50
12const READABLE = /\.(md|markdown|txt)$/i
13const PASTE = 'paste'
14
15type Source = { text: string; title: string; isFile: boolean }
16
17type Doc = {
18  id: string
19  title: string
20  pages: Para[][]
21  costs: Cost[]
22  pageIndex: number
23  wpm: number
24  resumeKey?: string
25  resumeAt?: number
26}
27
28// The open document; one pane, one document at a time.
29let doc: Doc | undefined
30
31// Set by /speedread paste: the pane width to open the next prompt at, in place of sending it.
32let pasteColumns: number | undefined
33
34const NO_COST: Cost = { units: 0, fixedMs: 0, words: 0 }
35
36const sum = (costs: Cost[]): Cost =>
37  costs.reduce(
38    (a, b) => ({ units: a.units + b.units, fixedMs: a.fixedMs + b.fixedMs, words: a.words + b.words }),
39    NO_COST,
40  )
41
42// FNV-1a: a cheap content fingerprint, so an edited file starts over.
43const fingerprint = (text: string): string => {
44  let hash = 0x811c9dc5
45  for (let i = 0; i < text.length; i++) {
46    hash ^= text.charCodeAt(i)
47    hash = Math.imul(hash, 0x01000193)
48  }
49
50  return `${(hash >>> 0).toString(16)}-${text.length}`
51}
52
53const propsFor = (d: Doc, pageIndex: number): ReaderProps => ({
54  docId: d.id,
55  title: d.title,
56  paras: d.pages[pageIndex] ?? [],
57  pageIndex,
58  pageCount: d.pages.length,
59  wpm: d.wpm,
60  ...(d.resumeAt !== undefined && { resumeAt: d.resumeAt }),
61  before: sum(d.costs.slice(0, pageIndex)),
62  after: sum(d.costs.slice(pageIndex + 1)),
63})
64
65const isSnapshot = (data: unknown): data is Snapshot =>
66  typeof data === 'object' && data !== null && 'mode' in data && 'pageIndex' in data && 'wpm' in data
67
68const asPosition = (value: unknown): { pageIndex: number; index: number } | undefined => {
69  if (typeof value !== 'object' || value === null) return undefined
70  const { pageIndex, index } = value as Record<string, unknown>
71
72  return typeof pageIndex === 'number' && typeof index === 'number' ? { pageIndex, index } : undefined
73}
74
75const describeError = (error: unknown): string => {
76  const message = error instanceof Error ? error.message : String(error)
77
78  return /ENOENT/.test(message) ? 'file not found' : message
79}
80
81const loadSource = async ($: EngineInterface, args: string): Promise<Source | string> => {
82  let path = args.trim().replace(/^["']|["']$/g, '')
83  if (path) {
84    if (!READABLE.test(path)) return 'reads .md and .txt files only.'
85    if (path.startsWith('~/')) path = `${(await $.env.get('HOME')) ?? '~'}${path.slice(1)}`
86    try {
87      return { text: await $.fs.read(path), title: path.split('/').pop() ?? path, isFile: true }
88    } catch (error) {
89      return `could not read ${path} (${describeError(error)}).`
90    }
91  }
92  const reply = (await $.session.messages()).findLast(m => m.role === 'assistant' && m.text.trim())
93
94  return reply
95    ? { text: reply.text, title: 'Claude’s last reply', isFile: false }
96    : 'there is no Claude reply to read yet.'
97}
98
99const pruneResumes = async ($: EngineInterface) => {
100  const keys = (await $.store.keys()).filter(key => key.startsWith(RESUME_PREFIX))
101  for (const key of keys.slice(0, Math.max(0, keys.length - MAX_RESUMES))) await $.store.delete(key)
102}
103
104const awaitPaste = ($: EngineInterface, columns: number | undefined) => {
105  pasteColumns = columns
106  $.ui.status(columns === undefined ? undefined : 'speedread: your next prompt opens in the reader, not Claude')
107}
108
109// Opens the pane on a source; returns why not, if it could not.
110const openReader = async (
111  $: EngineInterface,
112  source: Source,
113  columns: number,
114  listsAsBlocks: boolean,
115): Promise<string | undefined> => {
116  const pages = paginate(parse(source.text, listsAsBlocks))
117  if (!pages.length) return 'nothing to read there.'
118
119  const id = fingerprint(source.text)
120  const storedWpm = await $.store.get(WPM_KEY)
121  const resumeKey = source.isFile ? `${RESUME_PREFIX}${id}` : undefined
122  const resume = resumeKey ? asPosition(await $.store.get(resumeKey)) : undefined
123  const isResumable = resume !== undefined && resume.pageIndex < pages.length
124  doc = {
125    id,
126    title: source.title,
127    pages,
128    costs: pages.map(page => costOf(flatten(page))),
129    pageIndex: isResumable ? resume.pageIndex : 0,
130    wpm: clampWpm(typeof storedWpm === 'number' ? storedWpm : DEFAULT_WPM),
131    ...(resumeKey && { resumeKey }),
132    ...(isResumable && { resumeAt: resume.index }),
133  }
134  if (resumeKey) await pruneResumes($)
135
136  const opened = await $.ui.open({
137    id: PANE,
138    title: `speedread · ${source.title}`,
139    focus: true,
140    closeOnEscape: true,
141    holdToasts: true,
142    columns,
143  })
144  $.ui.invalidate('ui.render')
145
146  return opened.isPlaced ? undefined : `the pane could not be placed (${opened.reason}).`
147}
148
149export const register: Register = (on, options) => {
150  const listsAsBlocks = options.listsAsProse !== true
151
152  on('session.start', async ($, e, next) => {
153    await $.command.register({
154      name: 'speedread',
155      description: 'Read a markdown file, pasted text, or Claude’s last reply one word at a time',
156      argumentHint: '[file.md | paste]',
157    })
158
159    return next(e)
160  })
161
162  on('command.run', { command: 'speedread' }, async ($, e) => {
163    const columns = Math.max(40, Math.floor(e.presentation.columns * 0.85))
164    const wasAwaiting = pasteColumns !== undefined
165    awaitPaste($, undefined)
166    if (e.args.trim() === PASTE) {
167      if (wasAwaiting) return { text: 'paste cancelled.' }
168      awaitPaste($, columns)
169
170      return { text: 'paste your text and press Enter: it opens in the reader instead of going to Claude. /speedread paste again cancels.' }
171    }
172
173    const source = await loadSource($, e.args)
174    if (typeof source === 'string') return { text: source }
175    const problem = await openReader($, source, columns, listsAsBlocks)
176
177    return problem ? { text: problem } : {}
178  })
179
180  // After /speedread paste, the person's next prompt is the text to read; it never reaches Claude.
181  on('prompt.submit', async ($, e, next) => {
182    if (pasteColumns === undefined || e.origin.kind !== 'composer') return next(e)
183    const columns = pasteColumns
184    awaitPaste($, undefined)
185    const problem = await openReader($, { text: e.text, title: 'pasted text', isFile: false }, columns, listsAsBlocks)
186
187    return { drop: problem ?? 'opened in speedread, not sent to Claude.' }
188  }).catch(($, e, next) => (next.called ? next(e) : { drop: 'speedread could not open the reader; nothing was sent to Claude.' }))
189
190  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
191    if (e.surface !== 'terminal' && e.surface !== 'desktop') {
192      const { Text } = $.ui.resolve(e)
193
194      return <Text>speedread needs the terminal or the desktop app.</Text>
195    }
196    const { Client, Text } = $.ui.resolve(e)
197    if (!doc) return <Text dimColor>Nothing loaded. Run /speedread again.</Text>
198
199    return (
200      <Client
201        key="reader"
202        module="./reader.tsx"
203        props={propsFor(doc, doc.pageIndex)}
204        height={e.props.scroll.bodyRows}
205        flexGrow={1}
206      />
207    )
208  })
209
210  on('ui.message', async ($, e, next) => {
211    if (e.requestId !== PANE || !doc || !isSnapshot(e.data)) return next(e)
212    const snap = e.data
213    if (snap.mode === 'closing') {
214      await $.ui.close({ id: PANE })
215
216      return {}
217    }
218    if (snap.wpm !== doc.wpm) {
219      doc.wpm = snap.wpm
220      await $.store.set(WPM_KEY, snap.wpm)
221    }
222    if (snap.mode !== 'start' && snap.mode !== 'resume') {
223      delete doc.resumeAt
224      if (doc.resumeKey && snap.mode === 'done') await $.store.delete(doc.resumeKey)
225      else if (doc.resumeKey) await $.store.set(doc.resumeKey, { pageIndex: snap.pageIndex, index: snap.index })
226    }
227    if (snap.want) {
228      doc.pageIndex = snap.want.index
229
230      return { props: propsFor(doc, snap.want.index) }
231    }
232
233    return {}
234  })
235}
236
hooks/parse.ts 277 lines
1import { HEADING_MS, wordFactor } from './timing'
2
3// Markdown in, a flat list of things to show out: words to flash, headings to
4// card, and blocks (tables, code, lists...) to show whole until Enter.
5
6// Claude's replies are list-heavy, so lists show whole unless the listsAsProse option is on.
7export const LISTS_AS_BLOCKS = true
8
9// A Client's props are capped at 100k serialized chars; leave headroom.
10export const PAGE_LIMIT = 60000
11
12export type BlockKind = 'table' | 'code' | 'diagram' | 'list' | 'quote' | 'math' | 'image'
13
14export type Para =
15  | { kind: 'text'; sentences: string[][] }
16  | { kind: 'heading'; text: string }
17  | { kind: 'block'; block: BlockKind; markdown: string }
18
19type Place = { sentence: number; paragraph: number }
20
21export type Item =
22  | ({ kind: 'word'; text: string; factor: number } & Place)
23  | ({ kind: 'heading'; text: string } & Place)
24  | ({ kind: 'block'; block: BlockKind; markdown: string } & Place)
25
26const FENCE = /^(`{3,}|~{3,})\s*([\w+-]*)/
27const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
28const RULE = /^([-*_])(\s*\1){2,}\s*$/
29const IMAGE_LINE = /^!\[[^\]]*\]\([^)]*\)\s*$/
30const LIST_ITEM = /^\s{0,3}([-*+]|\d{1,9}[.)])\s+/
31const CONTINUATION = /^\s{2,}\S/
32const HTML_LINE = /^<\/?[a-zA-Z][^>]*>\s*$/
33const HAS_WORD = /[\p{L}\p{N}]/u
34const CLOSERS = `"')]’”`
35const SENTENCE_END = /[.!?]["')\]’”]*$/
36const ABBREVIATIONS = new Set([
37  'e.g.', 'i.e.', 'etc.', 'vs.', 'cf.', 'al.', 'approx.', 'fig.', 'no.', 'mr.', 'mrs.', 'ms.',
38  'dr.', 'st.', 'jr.', 'sr.', 'inc.', 'ltd.', 'co.', 'u.s.', 'u.k.', 'a.m.', 'p.m.',
39])
40
41export const stripInline = (text: string): string =>
42  text
43    .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
44    .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
45    .replace(/\[\^[^\]]+\]/g, '')
46    .replace(/<(https?:[^>]+)>/g, '$1')
47    .replace(/<\/?[a-zA-Z][^>]*>/g, '')
48    .replace(/`+/g, '')
49    .replace(/~~/g, '')
50
51// Emphasis markers come off the ends of a token only, so snake_case survives.
52const cleanToken = (token: string): string =>
53  token.replace(/^[*_]+/, '').replace(/[*_]+(?=["')\].,;:!?’”]*$)/, '')
54
55export const tokenize = (text: string): string[] => {
56  const tokens: string[] = []
57  for (const raw of stripInline(text).split(/\s+/)) {
58    const token = cleanToken(raw)
59    if (!token) continue
60    const previous = tokens.length - 1
61    // A lone dash or symbol rides on the word before it.
62    if (!HAS_WORD.test(token) && previous >= 0) tokens[previous] += ` ${token}`
63    else tokens.push(token)
64  }
65
66  return tokens
67}
68
69const isSentenceEnd = (token: string): boolean => {
70  if (!SENTENCE_END.test(token)) return false
71  const bare = token.replace(/^["'(\[‘“]+/, '').replace(new RegExp(`[${CLOSERS}\\]]+$`), '')
72  if (ABBREVIATIONS.has(bare.toLowerCase())) return false
73
74  return !/^\p{Lu}\.$/u.test(bare)
75}
76
77export const splitSentences = (tokens: string[]): string[][] => {
78  const sentences: string[][] = []
79  let current: string[] = []
80  for (const token of tokens) {
81    current.push(token)
82    if (isSentenceEnd(token)) {
83      sentences.push(current)
84      current = []
85    }
86  }
87  if (current.length) sentences.push(current)
88
89  return sentences
90}
91
92const textPara = (text: string): Para | undefined => {
93  const sentences = splitSentences(tokenize(text))
94
95  return sentences.length ? { kind: 'text', sentences } : undefined
96}
97
98const listItemTexts = (lines: string[]): string[] => {
99  const items: string[] = []
100  for (const line of lines) {
101    if (LIST_ITEM.test(line)) items.push(line.replace(LIST_ITEM, ''))
102    else if (line.trim() && items.length) items[items.length - 1] += ` ${line.trim()}`
103  }
104
105  return items
106}
107
108export const parse = (source: string, listsAsBlocks = LISTS_AS_BLOCKS): Para[] => {
109  const lines = source.replace(/\r\n?/g, '\n').split('\n')
110  const paras: Para[] = []
111  let text: string[] = []
112  let i = 0
113
114  const flush = () => {
115    const para = textPara(text.join(' '))
116    if (para) paras.push(para)
117    text = []
118  }
119  const takeWhile = (keep: (line: string) => boolean): string[] => {
120    const taken: string[] = []
121    while (i < lines.length && keep(lines[i] ?? '')) taken.push(lines[i++] ?? '')
122
123    return taken
124  }
125
126  // Front matter.
127  if (lines[0]?.trim() === '---') {
128    const end = lines.findIndex((line, n) => n > 0 && line.trim() === '---')
129    if (end > 0) i = end + 1
130  }
131
132  while (i < lines.length) {
133    const line = lines[i] ?? ''
134    const trimmed = line.trim()
135    const fence = FENCE.exec(trimmed)
136    const heading = HEADING.exec(trimmed)
137
138    if (!trimmed) {
139      flush()
140      i++
141    } else if (fence) {
142      flush()
143      const marker = fence[1] ?? '```'
144      i++
145      const body = takeWhile(next => !next.trim().startsWith(marker))
146      i++
147      const block: BlockKind = fence[2] === 'mermaid' ? 'diagram' : 'code'
148      paras.push({ kind: 'block', block, markdown: [line, ...body, marker].join('\n') })
149    } else if (trimmed.startsWith('$$')) {
150      flush()
151      const isOneLine = trimmed.length > 4 && trimmed.endsWith('$$')
152      i++
153      const body = isOneLine ? [] : takeWhile(next => !next.trim().endsWith('$$'))
154      const close = isOneLine ? [] : [lines[i++] ?? '']
155      paras.push({ kind: 'block', block: 'math', markdown: [line, ...body, ...close].join('\n') })
156    } else if (heading && !line.startsWith('    ')) {
157      flush()
158      paras.push({ kind: 'heading', text: tokenize(heading[2] ?? '').join(' ') })
159      i++
160    } else if (RULE.test(trimmed) || HTML_LINE.test(trimmed)) {
161      flush()
162      i++
163    } else if (trimmed.startsWith('|')) {
164      flush()
165      const rows = takeWhile(next => next.trim().startsWith('|'))
166      paras.push({ kind: 'block', block: 'table', markdown: rows.join('\n') })
167    } else if (trimmed.startsWith('>')) {
168      flush()
169      const quote = takeWhile(next => next.trim().startsWith('>'))
170      paras.push({ kind: 'block', block: 'quote', markdown: quote.join('\n') })
171    } else if (IMAGE_LINE.test(trimmed)) {
172      flush()
173      paras.push({ kind: 'block', block: 'image', markdown: trimmed })
174      i++
175    } else if (LIST_ITEM.test(line)) {
176      flush()
177      const items = takeWhile(next => {
178        if (LIST_ITEM.test(next) || CONTINUATION.test(next)) return true
179        if (next.trim()) return false
180        const after = lines[i + 1] ?? ''
181
182        return LIST_ITEM.test(after) || CONTINUATION.test(after)
183      })
184      if (listsAsBlocks) paras.push({ kind: 'block', block: 'list', markdown: items.join('\n') })
185      else for (const item of listItemTexts(items)) {
186        const para = textPara(item)
187        if (para) paras.push(para)
188      }
189    } else {
190      text.push(trimmed)
191      i++
192    }
193  }
194  flush()
195
196  return paras
197}
198
199export const flatten = (paras: Para[]): Item[] => {
200  const items: Item[] = []
201  let sentence = 0
202  paras.forEach((para, paragraph) => {
203    if (para.kind !== 'text') {
204      items.push({ ...para, sentence: sentence++, paragraph })
205
206      return
207    }
208    const lastSentence = para.sentences.length - 1
209    para.sentences.forEach((words, s) => {
210      words.forEach((text, w) => {
211        const isParagraphEnd = s === lastSentence && w === words.length - 1
212        items.push({ kind: 'word', text, factor: wordFactor(text, isParagraphEnd), sentence, paragraph })
213      })
214      sentence++
215    })
216  })
217
218  return items
219}
220
221const size = (value: unknown): number => JSON.stringify(value).length
222
223// Oversized single paragraphs are split by sentence, oversized blocks truncated.
224const fitPara = (para: Para, limit: number): Para[] => {
225  if (size(para) <= limit) return [para]
226  if (para.kind === 'block') {
227    const note = '\n\n… (truncated: too large to show whole)'
228
229    return [{ ...para, markdown: para.markdown.slice(0, limit - 1000) + note }]
230  }
231  if (para.kind === 'heading') return [{ ...para, text: para.text.slice(0, limit - 100) }]
232  const parts: Para[] = []
233  let sentences: string[][] = []
234  for (const sentence of para.sentences) {
235    if (sentences.length && size(sentences) + size(sentence) > limit - 100) {
236      parts.push({ kind: 'text', sentences })
237      sentences = []
238    }
239    sentences.push(sentence)
240  }
241  if (sentences.length) parts.push({ kind: 'text', sentences })
242
243  return parts
244}
245
246export const paginate = (paras: Para[], limit = PAGE_LIMIT): Para[][] => {
247  const pages: Para[][] = []
248  let page: Para[] = []
249  let used = 2
250  for (const para of paras.flatMap(one => fitPara(one, limit))) {
251    const cost = size(para) + 1
252    if (page.length && used + cost > limit) {
253      pages.push(page)
254      page = []
255      used = 2
256    }
257    page.push(para)
258    used += cost
259  }
260  if (page.length) pages.push(page)
261
262  return pages
263}
264
265// What a stretch of items costs to read: word units (× the base delay) plus fixed ms.
266export type Cost = { units: number; fixedMs: number; words: number }
267
268export const costOf = (items: Item[]): Cost =>
269  items.reduce(
270    (cost, item) => ({
271      units: cost.units + (item.kind === 'word' ? item.factor : 0),
272      fixedMs: cost.fixedMs + (item.kind === 'heading' ? HEADING_MS : 0),
273      words: cost.words + (item.kind === 'word' ? 1 : 0),
274    }),
275    { units: 0, fixedMs: 0, words: 0 },
276  )
277
hooks/reader-core.ts 227 lines
1import type { Cost, Item, Para } from './parse'
2import { HEADING_MS, WPM_STEP, baseMs, clampWpm } from './timing'
3
4// The reader as a pure state machine: register.tsx feeds it pages, reader.tsx
5// feeds it ticks, clicks and keys, and draws whatever state comes back.
6
7// Back restarts the current sentence after this long in it, else goes one further.
8export const BACK_RESTART_MS = 1500
9
10// Coming back to words after the eye was elsewhere, the focal point blinks this long first.
11export const CUE_MS = 1000
12export const BLINK_MS = 250
13
14export type Mode =
15  | 'start'
16  | 'resume'
17  | 'cue'
18  | 'playing'
19  | 'paused'
20  | 'heading'
21  | 'block'
22  | 'waiting'
23  | 'done'
24  | 'closing'
25
26export type Landing = 'start' | 'end'
27
28// What the hooks module hands the Client: one page of the document.
29export type ReaderProps = {
30  docId: string
31  title: string
32  paras: Para[]
33  pageIndex: number
34  pageCount: number
35  wpm: number
36  // Item index on this page to offer resuming at.
37  resumeAt?: number
38  before: Cost
39  after: Cost
40}
41
42// What the Client posts back: a snapshot, so a later post loses nothing.
43export type Snapshot = {
44  mode: Mode
45  pageIndex: number
46  index: number
47  wpm: number
48  want?: { index: number; at: Landing }
49}
50
51export type ReaderState = {
52  mode: Mode
53  pageIndex: number
54  index: number
55  wpm: number
56  scroll: number
57  isCueShown: boolean
58  want?: { index: number; at: Landing }
59}
60
61// Time spent on the current item and sentence; kept apart so ticks don't redraw.
62export type Clock = { itemMs: number; sentenceMs: number }
63
64export type Step = { state: ReaderState; clock: Clock }
65
66export type Page = { items: Item[]; pageIndex: number; pageCount: number; resumeAt?: number }
67
68const ZERO: Clock = { itemMs: 0, sentenceMs: 0 }
69
70export const initial = (page: Page, wpm: number): Step => ({
71  state: { mode: 'start', pageIndex: page.pageIndex, index: 0, wpm: clampWpm(wpm), scroll: 0, isCueShown: false },
72  clock: ZERO,
73})
74
75const modeFor = (item: Item | undefined): Mode =>
76  item?.kind === 'block' ? 'block' : item?.kind === 'heading' ? 'heading' : 'playing'
77
78export const sentenceStart = (items: Item[], index: number): number => {
79  const sentence = items[index]?.sentence
80
81  return Math.max(0, items.findIndex(item => item.sentence === sentence))
82}
83
84// Views that take the eye off the focal point: words resuming after one get a cue.
85const AWAY: ReadonlySet<Mode> = new Set(['start', 'resume', 'paused', 'block', 'done'])
86
87const cue = (step: Step): Step => ({
88  state: { ...step.state, mode: 'cue', isCueShown: true },
89  clock: { ...step.clock, itemMs: 0 },
90})
91
92const goTo = (step: Step, items: Item[], index: number, isSameSentence: boolean): Step => {
93  const mode = modeFor(items[index])
94  const moved: Step = {
95    state: { ...step.state, mode, index, scroll: 0, want: undefined },
96    clock: { itemMs: 0, sentenceMs: isSameSentence ? step.clock.sentenceMs : 0 },
97  }
98
99  return mode === 'playing' && AWAY.has(step.state.mode) ? cue(moved) : moved
100}
101
102const withMode = (step: Step, mode: Mode): Step => ({ ...step, state: { ...step.state, mode } })
103
104const request = (step: Step, index: number, at: Landing): Step => ({
105  ...step,
106  state: { ...step.state, mode: 'waiting', want: { index, at } },
107})
108
109export const advance = (step: Step, page: Page): Step => {
110  const next = step.state.index + 1
111  const { items } = page
112  if (next < items.length) return goTo(step, items, next, items[next]?.sentence === items[step.state.index]?.sentence)
113  if (page.pageIndex + 1 < page.pageCount) return request(step, page.pageIndex + 1, 'start')
114
115  return withMode(step, 'done')
116}
117
118export const tick = (step: Step, page: Page, ms: number): Step => {
119  const { state, clock } = step
120  const item = page.items[state.index]
121  if (state.mode === 'cue') {
122    const itemMs = clock.itemMs + ms
123    if (itemMs >= CUE_MS) return { state: { ...state, mode: 'playing' }, clock: { ...clock, itemMs: 0 } }
124    const isCueShown = Math.floor(itemMs / BLINK_MS) % 2 === 0
125
126    return { state: isCueShown === state.isCueShown ? state : { ...state, isCueShown }, clock: { ...clock, itemMs } }
127  }
128  if (state.mode === 'heading') {
129    const itemMs = clock.itemMs + ms
130    if (itemMs >= HEADING_MS) return advance(step, page)
131
132    return { state, clock: { ...clock, itemMs } }
133  }
134  if (state.mode !== 'playing' || item?.kind !== 'word') return step
135  const next = { itemMs: clock.itemMs + ms, sentenceMs: clock.sentenceMs + ms }
136  if (next.itemMs >= baseMs(state.wpm) * item.factor) return advance({ state, clock: next }, page)
137
138  return { state, clock: next }
139}
140
141export const back = (step: Step, page: Page): Step => {
142  const { items } = page
143  const index = step.state.mode === 'done' ? items.length - 1 : step.state.index
144  const current = items[index]?.sentence ?? 0
145  const target = step.clock.sentenceMs > BACK_RESTART_MS ? current : current - 1
146  const first = items.findIndex(item => item.sentence === target)
147  if (first >= 0) return goTo(step, items, first, false)
148  if (page.pageIndex > 0) return request(step, page.pageIndex - 1, 'end')
149
150  return goTo(step, items, 0, false)
151}
152
153export const click = (step: Step, page: Page): Step => {
154  if (step.state.mode !== 'start') return step
155  const isResumable = page.resumeAt !== undefined && (page.resumeAt > 0 || page.pageIndex > 0)
156
157  return isResumable ? withMode(step, 'resume') : goTo(step, page.items, 0, false)
158}
159
160const isSpace = (key: string) => key === ' ' || key.toLowerCase() === 'space'
161
162export const press = (step: Step, page: Page, key: string): Step => {
163  const { state } = step
164  const { items } = page
165
166  if (state.mode === 'start' || state.mode === 'waiting' || state.mode === 'closing') return step
167  if (state.mode === 'resume') {
168    if (key === 'return') return goTo(step, items, sentenceStart(items, page.resumeAt ?? 0), false)
169    if (key === 'r') return page.pageIndex > 0 ? request(step, 0, 'start') : goTo(step, items, 0, false)
170
171    return step
172  }
173  if (key === 'q') return withMode(step, 'closing')
174  if (key === 'left') return back(step, page)
175  if (key === 'up' || key === 'down') {
176    const delta = key === 'up' ? 1 : -1
177    if (state.mode === 'block') {
178      return { ...step, state: { ...state, scroll: Math.max(0, state.scroll - delta) } }
179    }
180
181    return { ...step, state: { ...state, wpm: clampWpm(state.wpm + delta * WPM_STEP) } }
182  }
183  if (key === 'return') {
184    if (state.mode === 'block') return advance(step, page)
185    if (state.mode === 'done') return withMode(step, 'closing')
186
187    return step
188  }
189  if (isSpace(key)) {
190    if (state.mode === 'playing' || state.mode === 'heading' || state.mode === 'cue') return withMode(step, 'paused')
191    if (state.mode === 'paused') {
192      const mode = modeFor(items[state.index])
193
194      return mode === 'playing' ? cue(step) : withMode(step, mode)
195    }
196  }
197
198  return step
199}
200
201// A requested page arrived: land at its start, or at its last sentence going back.
202export const land = (step: Step, page: Page): Step => {
203  const { want } = step.state
204  if (step.state.mode !== 'waiting' || !want || want.index !== page.pageIndex) return step
205  const index = want.at === 'start' ? 0 : sentenceStart(page.items, page.items.length - 1)
206  const landed = goTo(step, page.items, index, false)
207
208  return { ...landed, state: { ...landed.state, pageIndex: page.pageIndex } }
209}
210
211export const snapshot = (step: Step, page: Page): Snapshot => ({
212  mode: step.state.mode,
213  pageIndex: step.state.pageIndex,
214  index: sentenceStart(page.items, step.state.index),
215  wpm: step.state.wpm,
216  ...(step.state.want && { want: step.state.want }),
217})
218
219export const isSameSnapshot = (a: Snapshot | undefined, b: Snapshot): boolean =>
220  a !== undefined &&
221  a.mode === b.mode &&
222  a.pageIndex === b.pageIndex &&
223  a.index === b.index &&
224  a.wpm === b.wpm &&
225  a.want?.index === b.want?.index &&
226  a.want?.at === b.want?.at
227
hooks/timing.ts 39 lines
1// How long each word stays on screen, and where the eye fixes inside it.
2
3export const MIN_WPM = 100
4export const MAX_WPM = 1000
5export const WPM_STEP = 25
6export const DEFAULT_WPM = 300
7export const HEADING_MS = 1500
8
9// Optimal recognition point: slightly left of centre (OpenSpritz buckets).
10export const orpIndex = (word: string): number => {
11  const length = word.length
12  if (length <= 1) return 0
13  if (length <= 5) return 1
14  if (length <= 9) return 2
15  if (length <= 13) return 3
16
17  return 4
18}
19
20const SENTENCE_END = /[.!?]["')\]’”]*$/
21const CLAUSE_END = /([,;:]|—|–)["')\]’”]*$/
22const DENSE = /\d|^[A-Z]{2,}\W*$/
23
24// Multiplier on the base delay (60000 / wpm) for one word.
25export const wordFactor = (word: string, isParagraphEnd: boolean): number => {
26  let factor = 1
27  if (word.length >= 9) factor *= 1.3
28  if (DENSE.test(word)) factor *= 1.4
29  if (isParagraphEnd) factor *= 3
30  else if (SENTENCE_END.test(word)) factor *= 2.2
31  else if (CLAUSE_END.test(word)) factor *= 1.6
32
33  return factor
34}
35
36export const baseMs = (wpm: number): number => 60000 / wpm
37
38export const clampWpm = (wpm: number): number => Math.min(MAX_WPM, Math.max(MIN_WPM, wpm))
39
hooks/reader.tsx 268 lines
1import type { ClientModule, RenderElement } from 'claude-code'
2
3import type { BlockKind, Cost, Item } from './parse'
4import { costOf, flatten } from './parse'
5import type { Clock, Page, ReaderProps, ReaderState, Snapshot, Step } from './reader-core'
6import { click, initial, isSameSnapshot, land, press, snapshot, tick } from './reader-core'
7import { baseMs, orpIndex } from './timing'
8
9const TICK_MS = 20
10const CONTEXT_WORDS = 60
11const FRESH: Clock = { itemMs: 0, sentenceMs: 0 }
12
13const BLOCK_LABEL: Record<BlockKind, string> = {
14  table: 'Table',
15  code: 'Code',
16  diagram: 'Diagram',
17  list: 'List',
18  quote: 'Quote',
19  math: 'Math',
20  image: 'Image',
21}
22
23// One reader instance at a time: its page, clock and last post live beside its state.
24let docId = ''
25let page: Page = { items: [], pageIndex: 0, pageCount: 1 }
26let pageKey = ''
27let remaining: Cost[] = []
28let clock: Clock = FRESH
29let posted: Snapshot | undefined
30
31const derive = (props: ReaderProps) => {
32  const key = `${props.docId}:${props.pageIndex}`
33  if (key === pageKey) return
34  pageKey = key
35  const items = flatten(props.paras)
36  page = {
37    items,
38    pageIndex: props.pageIndex,
39    pageCount: props.pageCount,
40    ...(props.resumeAt !== undefined && { resumeAt: props.resumeAt }),
41  }
42  // remaining[i]: what items[i..] cost to read.
43  remaining = new Array<Cost>(items.length + 1)
44  remaining[items.length] = { units: 0, fixedMs: 0, words: 0 }
45  for (let i = items.length - 1; i >= 0; i--) {
46    const own = costOf(items.slice(i, i + 1))
47    const rest = remaining[i + 1] as Cost
48    remaining[i] = { units: own.units + rest.units, fixedMs: own.fixedMs + rest.fixedMs, words: own.words + rest.words }
49  }
50}
51
52const formatLeft = (ms: number): string => {
53  const minutes = Math.round(ms / 60000)
54  if (minutes >= 1) return `~${minutes} min left`
55
56  return `~${Math.max(1, Math.round(ms / 1000))} s left`
57}
58
59// Show a block whole, keeping a table's header and a fence's markers while scrolling.
60// A drawn table takes about two rows per markdown row (a border between rows).
61const windowBlock = (item: Extract<Item, { kind: 'block' }>, room: number, scroll: number, width: number) => {
62  const lines = item.markdown.split('\n')
63  const isTable = item.block === 'table'
64  const isFenced = item.block === 'code' || item.block === 'diagram' || item.block === 'math'
65  const head = isTable ? lines.slice(0, 2) : isFenced ? lines.slice(0, 1) : []
66  const tail = isFenced && lines.length > 1 ? lines.slice(-1) : []
67  const body = lines.slice(head.length, lines.length - tail.length)
68  const fits = isTable ? Math.max(1, Math.floor((room - 3) / 2)) : Math.max(1, room - head.length - tail.length)
69  const maxScroll = Math.max(0, body.length - fits)
70  const from = Math.min(scroll, maxScroll)
71  const shown = body.slice(from, from + fits)
72  const height = isTable
73    ? 3 + 2 * shown.length
74    : [...head, ...shown, ...tail].reduce((rows, line) => rows + Math.max(1, Math.ceil(line.length / width)), 0)
75
76  return { text: [...head, ...shown, ...tail].join('\n'), canScroll: maxScroll > 0, height }
77}
78
79const Reader: ClientModule<ReaderProps, ReaderState> = (props, surface) => {
80  const { Box, Text, Markdown } = surface.elements
81  const isNew = surface.state === undefined
82  // A new instance, or /speedread run again on another document: start fresh.
83  const isFresh = isNew || props.docId !== docId
84  if (isFresh) {
85    docId = props.docId
86    pageKey = ''
87    clock = FRESH
88    posted = undefined
89  }
90  derive(props)
91
92  const current = (): Step => ({ state: surface.state ?? initial(page, props.wpm).state, clock })
93  const commit = (step: Step) => {
94    clock = step.clock
95    if (step.state !== surface.state) surface.setState(step.state)
96    const snap = snapshot(step, page)
97    if (snap.mode === 'start' || isSameSnapshot(posted, snap)) return
98    posted = snap
99    surface.post(snap)
100  }
101
102  if (isFresh) surface.setState(initial(page, props.wpm).state)
103  if (isNew) {
104    surface.every(TICK_MS, () => commit(tick(current(), page, TICK_MS)))
105    surface.onPointer(event => {
106      if (event.type === 'down') commit(click(current(), page))
107    })
108    surface.onKey(event => commit(press(current(), page, event.key)))
109  }
110
111  let state = isFresh ? initial(page, props.wpm).state : current().state
112  if (state.mode === 'waiting') {
113    const landed = land(current(), page)
114    if (landed.state !== state) {
115      commit(landed)
116      state = landed.state
117    }
118  }
119
120  const columns = surface.columns || 80
121  const rows = surface.rows || 20
122  const center = Math.floor(columns / 2)
123  const item = page.items[state.index]
124  const before = props.before
125  const pageWords = remaining[0]?.words ?? 0
126  const totalWords = before.words + pageWords + props.after.words
127  const wordsAt = (index: number) => before.words + pageWords - (remaining[index]?.words ?? 0)
128  const left = remaining[state.index] ?? remaining[remaining.length - 1]
129  const leftMs =
130    ((left?.units ?? 0) + props.after.units) * baseMs(state.wpm) + (left?.fixedMs ?? 0) + props.after.fixedMs
131  const done = state.mode === 'done' ? totalWords : wordsAt(state.index)
132  const percent = totalWords ? Math.round((done / totalWords) * 100) : 100
133
134  // The focal column's tick marks around one row: a word, or the cue dot.
135  const framed = (row: RenderElement) => (
136    <Box flexDirection="column">
137      <Box marginLeft={center}>
138        <Text dimColor>│</Text>
139      </Box>
140      {row}
141      <Box marginLeft={center}>
142        <Text dimColor>│</Text>
143      </Box>
144    </Box>
145  )
146
147  const focal = (word: string) => {
148    const k = orpIndex(word)
149    const pad = Math.max(0, Math.min(center - k, columns - word.length - 1))
150
151    return framed(
152      <Box key="word" flexDirection="row" marginLeft={pad}>
153        <Text>{word.slice(0, k)}</Text>
154        <Text color="error" bold>
155          {word.slice(k, k + 1)}
156        </Text>
157        <Text>{word.slice(k + 1)}</Text>
158      </Box>,
159    )
160  }
161
162  // Where the next word lands, blinking, so the eye can find it first.
163  const cueDot = () =>
164    framed(
165      <Box key="cue" marginLeft={center}>
166        <Text color="error" bold>
167          {state.isCueShown ? '●' : ' '}
168        </Text>
169      </Box>,
170    )
171
172  const room = rows - 5
173  const block = state.mode === 'block' && item?.kind === 'block' ? windowBlock(item, room, state.scroll, columns - 2) : undefined
174
175  const centered = (main: string, sub?: string) => (
176    <Box flexDirection="column" alignItems="center">
177      <Text bold>{main}</Text>
178      {sub && <Text dimColor>{sub}</Text>}
179    </Box>
180  )
181
182  // The paragraph around the current word, so a pause can re-read without stepping back.
183  const context = () => {
184    if (item?.kind !== 'word') return undefined
185    const words = page.items.filter(one => one.kind === 'word' && one.paragraph === item.paragraph)
186    const at = words.indexOf(item)
187    const from = Math.max(0, at - CONTEXT_WORDS)
188    const to = Math.min(words.length, at + CONTEXT_WORDS + 1)
189    const text = (list: Item[]) => list.map(one => (one.kind === 'word' ? one.text : '')).join(' ')
190
191    return (
192      <Box marginTop={1} paddingX={2}>
193        <Text dimColor>
194          {from > 0 ? '… ' : ''}
195          {text(words.slice(from, at))} <Text inverse>{item.text}</Text> {text(words.slice(at + 1, to))}
196          {to < words.length ? ' …' : ''}
197        </Text>
198      </Box>
199    )
200  }
201
202  const body = () => {
203    switch (state.mode) {
204      case 'start':
205        return centered('Click here to start', 'Then: Space pause · ← back · ↑↓ speed · Enter past tables · q close')
206      case 'resume':
207        return centered(
208          `Resume where you left off (${Math.round((wordsAt(page.resumeAt ?? 0) / Math.max(1, totalWords)) * 100)}%)?`,
209          'Enter resume · r start over',
210        )
211      case 'done':
212        return centered(`Done · ${totalWords} words`, 'Enter close · ← go back')
213      case 'waiting':
214      case 'closing':
215        return <Text> </Text>
216      case 'heading':
217        return item?.kind === 'heading' ? centered(item.text) : undefined
218      case 'block':
219        if (item?.kind !== 'block' || !block) return undefined
220
221        return (
222          <Box flexDirection="column" alignItems="center" paddingX={1}>
223            <Text dimColor>
224              {BLOCK_LABEL[item.block]} · Enter to continue{block.canScroll ? ' · ↑↓ scroll' : ''} · ← back
225            </Text>
226            <Markdown text={block.text} />
227          </Box>
228        )
229      case 'cue':
230        return cueDot()
231      case 'paused':
232        if (item?.kind === 'heading') return centered(item.text, 'paused')
233
234        return (
235          <Box flexDirection="column">
236            {item?.kind === 'word' && focal(item.text)}
237            {context()}
238          </Box>
239        )
240      default:
241        return item?.kind === 'word' ? focal(item.text) : undefined
242    }
243  }
244
245  const label = ` ${percent}% · ${formatLeft(leftMs)} · ${state.wpm} wpm`
246  const barWidth = Math.max(0, columns - label.length - 1)
247  const filled = Math.round((barWidth * percent) / 100)
248  const isPlaying = state.mode === 'playing' || state.mode === 'heading' || state.mode === 'cue'
249  // Centre everything; only a block too tall to fit starts at the top.
250  const isTallBlock = block !== undefined && block.height > room
251
252  return (
253    <Box flexDirection="column" height={rows}>
254      <Box flexGrow={1} flexDirection="column" justifyContent={isTallBlock ? 'flex-start' : 'center'} overflow="hidden">
255        {body()}
256      </Box>
257      <Box flexDirection="row">
258        <Text color="claude">{'━'.repeat(filled)}</Text>
259        <Text dimColor>{'─'.repeat(barWidth - filled)}</Text>
260        <Text dimColor>{label}</Text>
261      </Box>
262      <Text dimColor>{isPlaying ? ' ' : 'Space play/pause · ← back · ↑↓ speed · Enter continue · q close'}</Text>
263    </Box>
264  )
265}
266
267export default Reader
268