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

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.
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.
| Command | Reads |
|---|---|
/speedread | Claude's last reply |
/speedread notes.md | A .md, .markdown, or .txt file. ~/ paths work. |
/speedread paste | The 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.
| Key | Does |
|---|---|
Space | Pause 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. |
Enter | Continue past a table, code block, or list. Accept the resume prompt. Close when finished. |
r | Start over instead of resuming. |
q / Esc | Close the reader. |
The bar along the bottom shows percent read, estimated time left, and current speed.
60000 / wpm ms. Long words, numbers, and acronyms stay up longer. Words that end a clause, sentence, or paragraph pause longer still.$$ math, images) render as markdown and wait for Enter. Lists can play word by word instead; see Settings.| Setting | Default | Does |
|---|---|---|
| Play lists word by word | Off | Lists 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.
Replies and pasted text are not saved.
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.
.md, .markdown, and .txt only. No PDFs./speedread again replaces what is open.claude plugin marketplace update b-sides
claude plugin update speedread@b-sides
Restart Claude Code after updating.
claude plugin uninstall speedread@b-sides
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"]
| File | Role |
|---|---|
hooks/register.tsx | Registers /speedread, intercepts the prompt in paste mode, opens the pane, and saves speed and position. |
hooks/parse.ts | Turns 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.tsx | The pane. Runs a 20 ms tick, takes keys and clicks, and draws the current state. |
hooks/reader-core.ts | Every reader transition (play, pause, back, page turns, resume) as pure functions. Most tests live here. |
hooks/timing.ts | Speed limits, per-word delay, and the focal letter's position. |
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.
The focal letter positions follow OpenSpritz.
hooks/register.tsx 236 lines1import 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}
236hooks/parse.ts 277 lines1import { 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 )
277hooks/reader-core.ts 227 lines1import 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
227hooks/timing.ts 39 lines1// 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))
39hooks/reader.tsx 268 lines1import 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