Speed-read Claude's answers

claude code mod that speed-reads claude's answers.
claude plugin marketplace add boris-schwarz/read
claude plugin install read@boris-schwarz
Or in one go (Claude Code ≥ 2.1.292):
claude plugin install read --marketplace boris-schwarz/read
Update later with claude plugin update read@boris-schwarz.
Load the working copy without installing:
claude --plugin-dir .hooks/register.js 264 lines1const PANE = "read";
2const PIVOT = 10;
3const MIN_WPM = 100;
4const MAX_WPM = 1200;
5const WPM_STEP = 25;
6
7let words = [];
8let idx = 0;
9let playing = false;
10let wpm = 350;
11let timer = null;
12
13function clamp(v, a, b) {
14 return Math.max(a, Math.min(b, v));
15}
16
17function orpIndex(len) {
18 if (len <= 1) return 0;
19 if (len <= 5) return 1;
20 if (len <= 9) return 2;
21 if (len <= 13) return 3;
22 return 4;
23}
24
25function baseMs() {
26 return Math.max(25, Math.round(60000 / wpm));
27}
28function wordMs(i) {
29 return Math.round(baseMs() * (words[i]?.mult || 1));
30}
31
32function buildWords(md) {
33 let t = md;
34 t = t.replace(/```[\s\S]*?```/g, " ").replace(/~~~[\s\S]*?~~~/g, " ");
35 t = t.replace(/`([^`]*)`/g, "$1");
36 t = t
37 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
38 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
39
40 const out = [];
41 for (const raw of t.split("\n")) {
42 const h = raw.match(/^\s{0,3}(#{1,6})\s+(.*)$/);
43 const heading = !!h;
44 let line = heading ? h[2] : raw;
45 line = line
46 .replace(/\*\*([^*]*)\*\*/g, "$1")
47 .replace(/\*([^*]*)\*/g, "$1")
48 .replace(/__([^_]*)__/g, "$1")
49 .replace(/_([^_]*)_/g, "$1")
50 .replace(/^\s*[-*+]\s+/, "")
51 .replace(/^\s*\d+\.\s+/, "")
52 .replace(/^\s*>\s?/, "")
53 .replace(/[#*_`]/g, "");
54
55 const toks = line.split(/\s+/).filter(Boolean);
56 if (toks.length === 0) {
57 if (out.length)
58 out[out.length - 1].mult = Math.max(out[out.length - 1].mult, 2.5);
59 continue;
60 }
61 toks.forEach((tok, j) => {
62 let mult = 1;
63 if (/[,;:]$/.test(tok)) mult = 1.6;
64 if (/[.!?]+["')\]]?$/.test(tok)) mult = 2.2;
65 if (heading && j === toks.length - 1) mult = Math.max(mult, 3);
66 out.push({ word: tok, mult });
67 });
68 }
69 return out;
70}
71
72function stopTimer() {
73 if (timer) {
74 timer.cancel();
75 timer = null;
76 }
77}
78
79function scheduleNext($) {
80 stopTimer();
81 if (!playing || idx >= words.length) return;
82 timer = $.clock.after(wordMs(idx), () => {
83 if (!playing) return;
84 if (idx >= words.length - 1) {
85 playing = false;
86 idx = words.length - 1;
87 $.ui.invalidate("ui.render");
88 return;
89 }
90 idx += 1;
91 $.ui.invalidate("ui.render");
92 scheduleNext($);
93 });
94}
95
96function play($) {
97 if (playing || !words.length) return;
98 if (idx >= words.length - 1) idx = 0;
99 playing = true;
100 $.ui.invalidate("ui.render");
101 scheduleNext($);
102}
103
104function pause($) {
105 if (!playing) return;
106 playing = false;
107 stopTimer();
108 $.ui.invalidate("ui.render");
109}
110
111function togglePlay($) {
112 if (playing) pause($);
113 else play($);
114}
115
116function setWpm($, newWpm) {
117 wpm = clamp(newWpm, MIN_WPM, MAX_WPM);
118 $.store.set("wpm", wpm);
119 $.ui.invalidate("ui.render");
120 if (playing) scheduleNext($);
121}
122
123async function openPane($) {
124 await $.ui.open({
125 id: PANE,
126 title: "Speed Reader",
127 focus: true,
128 closeOnEscape: true,
129 });
130}
131
132export function register(on) {
133 on("session.start", async ($, e, next) => {
134 try {
135 await $.command.register({
136 name: "read",
137 description: "Speed-read Claude's last answer",
138 });
139 } catch (_) {
140 /* name taken */
141 }
142 const saved = await $.store.get("wpm");
143 if (typeof saved === "number" && saved >= MIN_WPM && saved <= MAX_WPM)
144 wpm = saved;
145 return next(e);
146 });
147
148 on("turn.complete", async ($, e, next) => {
149 const result = await next(e);
150 if (
151 !e.agentId &&
152 !e.isAborted &&
153 typeof e.answer === "string" &&
154 e.answer.trim()
155 ) {
156 stopTimer();
157 playing = false;
158 words = buildWords(e.answer);
159 idx = 0;
160 $.ui.invalidate("ui.render");
161 }
162 return result;
163 });
164
165 on("prompt.submit", async ($, e, next) => {
166 stopTimer();
167 playing = false;
168 try {
169 await $.ui.close({ id: PANE });
170 } catch (_) {
171 /* not open */
172 }
173 return next(e);
174 });
175
176 on("command.run", { command: "read" }, async ($) => {
177 await openPane($);
178 return {};
179 });
180
181 on("ui.render", { component: "Pane" }, async ($, e, next) => {
182 if (e.requestId !== PANE) return next(e);
183 const { Box, Text, Button } = $.ui.resolve(e);
184
185 if (!words.length) {
186 return Text({
187 children: ["No answer yet — ask Claude something, then reopen."],
188 });
189 }
190
191 const i = clamp(idx, 0, words.length - 1);
192 const word = words[i].word;
193 const chars = [...word];
194 const orp = orpIndex(chars.length);
195 const left = chars.slice(0, orp).join("");
196 const piv = chars[orp] ?? "";
197 const right = chars.slice(orp + 1).join("");
198 const pad = " ".repeat(Math.max(0, PIVOT - left.length));
199 const marker = " ".repeat(PIVOT);
200
201 return Box({
202 flexDirection: "column",
203 children: [
204 Text({ children: [`${marker}▾`], dimColor: true }),
205 Box({
206 flexDirection: "row",
207 children: [
208 Text({ children: [pad + left], color: "white" }),
209 Text({ children: [piv], color: "#ff8c00", bold: true }),
210 Text({ children: [right], color: "white" }),
211 ],
212 }),
213 Text({ children: [`${marker}▴`], dimColor: true }),
214 Text({ children: [" "] }),
215 Box({
216 flexDirection: "row",
217 columnGap: 3,
218 children: [
219 Button({
220 key: "playpause",
221 label: playing ? "pause" : "play",
222 hotkey: "p",
223 plain: true,
224 onPress: () => togglePlay($),
225 }),
226 Button({
227 key: "slower",
228 label: "slower",
229 hotkey: "j",
230 plain: true,
231 onPress: () => setWpm($, wpm - WPM_STEP),
232 }),
233 Button({
234 key: "faster",
235 label: "faster",
236 hotkey: "k",
237 plain: true,
238 onPress: () => setWpm($, wpm + WPM_STEP),
239 }),
240 Text({ children: [` ${wpm} wpm`], dimColor: true }),
241 ],
242 }),
243 ],
244 });
245 });
246
247 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
248 const { Box, Button } = $.ui.resolve(e);
249 const theirs = await next(e);
250 const openButton = Button({
251 key: "open-reader",
252 label: "Read answer",
253 hotkey: "1",
254 plain: true,
255 onPress: () => void openPane($),
256 });
257 return Box({
258 flexDirection: "row",
259 columnGap: 2,
260 children: [openButton, theirs],
261 });
262 });
263}
264